From f25c15bcd0be014f6dcc4d24186b7972a2ae5026 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 8 Mar 2026 23:06:11 +0000 Subject: [PATCH 01/19] refactor: remove custom MCP firewall, metadata, and built-in MCP concept Remove the custom MCP firewall proxy (src/mcp_firewall.rs), embedded tool metadata (src/mcp_metadata.rs, mcp-metadata.json), and all firewall tests. The copilot CLI no longer has built-in MCPs, so: - Replace BUILTIN_MCPS constant with is_custom_mcp() helper - Remove --disable-builtin-mcps, --disable-mcp-server, --mcp from copilot params - Remove mcp-firewall CLI subcommand - Simplify create wizard to MCP-level selection (no tool-level metadata) - Update 1ES compiler to use is_custom_mcp() check - Remove terminal_size dependency (only used by deleted MCP tool selector) All MCPs are now handled through the MCP Gateway (MCPG) instead of the custom firewall proxy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mcp-metadata.json | 4176 ----------------------------------- src/compile/common.rs | 54 +- src/compile/onees.rs | 20 +- src/create.rs | 193 +- src/main.rs | 31 +- src/mcp_firewall.rs | 776 ------- src/mcp_metadata.rs | 146 -- tests/mcp_firewall_tests.rs | 281 --- 8 files changed, 107 insertions(+), 5570 deletions(-) delete mode 100644 mcp-metadata.json delete mode 100644 src/mcp_firewall.rs delete mode 100644 src/mcp_metadata.rs delete mode 100644 tests/mcp_firewall_tests.rs diff --git a/mcp-metadata.json b/mcp-metadata.json deleted file mode 100644 index 33776c9..0000000 --- a/mcp-metadata.json +++ /dev/null @@ -1,4176 +0,0 @@ -{ - "version": "1.0", - "generated_at": "2026-01-29T15:08:44.279920500+00:00", - "mcps": { - "ado": { - "name": "ado", - "builtin": true, - "tools": [ - { - "name": "core_list_project_teams", - "description": "Retrieve a list of teams for the specified Azure DevOps project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "mine": { - "description": "If true, only return teams that the authenticated user is a member of.", - "type": "boolean" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "skip": { - "description": "The number of teams to skip for pagination. Defaults to 0.", - "type": "number" - }, - "top": { - "description": "The maximum number of teams to return. Defaults to 100.", - "type": "number" - } - }, - "required": [ - "project" - ], - "type": "object" - } - }, - { - "name": "core_list_projects", - "description": "Retrieve a list of projects in your Azure DevOps organization.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "continuationToken": { - "description": "Continuation token for pagination. Used to fetch the next set of results if available.", - "type": "number" - }, - "projectNameFilter": { - "description": "Filter projects by name. Supports partial matches.", - "type": "string" - }, - "skip": { - "description": "The number of projects to skip for pagination. Defaults to 0.", - "type": "number" - }, - "stateFilter": { - "default": "wellFormed", - "description": "Filter projects by their state. Defaults to 'wellFormed'.", - "enum": [ - "all", - "wellFormed", - "createPending", - "deleted" - ], - "type": "string" - }, - "top": { - "description": "The maximum number of projects to return. Defaults to 100.", - "type": "number" - } - }, - "type": "object" - } - }, - { - "name": "core_get_identity_ids", - "description": "Retrieve Azure DevOps identity IDs for a provided search filter.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "searchFilter": { - "description": "Search filter (unique name, display name, email) to retrieve identity IDs for.", - "type": "string" - } - }, - "required": [ - "searchFilter" - ], - "type": "object" - } - }, - { - "name": "work_list_team_iterations", - "description": "Retrieve a list of iterations for a specific team in a project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "team": { - "description": "The name or ID of the Azure DevOps team.", - "type": "string" - }, - "timeframe": { - "description": "The timeframe for which to retrieve iterations. Currently, only 'current' is supported.", - "enum": [ - "current" - ], - "type": "string" - } - }, - "required": [ - "project", - "team" - ], - "type": "object" - } - }, - { - "name": "work_create_iterations", - "description": "Create new iterations in a specified Azure DevOps project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "iterations": { - "description": "An array of iterations to create. Each iteration must have a name and can optionally have start and finish dates in ISO format.", - "items": { - "additionalProperties": false, - "properties": { - "finishDate": { - "description": "The finish date of the iteration in ISO format (e.g., '2023-01-31T23:59:59Z'). Optional.", - "type": "string" - }, - "iterationName": { - "description": "The name of the iteration to create.", - "type": "string" - }, - "startDate": { - "description": "The start date of the iteration in ISO format (e.g., '2023-01-01T00:00:00Z'). Optional.", - "type": "string" - } - }, - "required": [ - "iterationName" - ], - "type": "object" - }, - "type": "array" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - } - }, - "required": [ - "project", - "iterations" - ], - "type": "object" - } - }, - { - "name": "work_list_iterations", - "description": "List all iterations in a specified Azure DevOps project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "depth": { - "default": 2, - "description": "Depth of children to fetch.", - "type": "number" - }, - "excludedIds": { - "description": "An optional array of iteration IDs, and thier children, that should not be returned.", - "items": { - "type": "number" - }, - "type": "array" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - } - }, - "required": [ - "project" - ], - "type": "object" - } - }, - { - "name": "work_assign_iterations", - "description": "Assign existing iterations to a specific team in a project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "iterations": { - "description": "An array of iterations to assign. Each iteration must have an identifier and a path.", - "items": { - "additionalProperties": false, - "properties": { - "identifier": { - "description": "The identifier of the iteration to assign.", - "type": "string" - }, - "path": { - "description": "The path of the iteration to assign, e.g., 'Project/Iteration'.", - "type": "string" - } - }, - "required": [ - "identifier", - "path" - ], - "type": "object" - }, - "type": "array" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "team": { - "description": "The name or ID of the Azure DevOps team.", - "type": "string" - } - }, - "required": [ - "project", - "team", - "iterations" - ], - "type": "object" - } - }, - { - "name": "work_get_team_capacity", - "description": "Get the team capacity of a specific team and iteration in a project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "iterationId": { - "description": "The Iteration Id to get capacity for.", - "type": "string" - }, - "project": { - "description": "The name or Id of the Azure DevOps project.", - "type": "string" - }, - "team": { - "description": "The name or Id of the Azure DevOps team.", - "type": "string" - } - }, - "required": [ - "project", - "team", - "iterationId" - ], - "type": "object" - } - }, - { - "name": "work_update_team_capacity", - "description": "Update the team capacity of a team member for a specific iteration in a project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "activities": { - "description": "Array of activities and their daily capacities for the team member.", - "items": { - "additionalProperties": false, - "properties": { - "capacityPerDay": { - "description": "The capacity per day for this activity.", - "type": "number" - }, - "name": { - "description": "The name of the activity (e.g., 'Development').", - "type": "string" - } - }, - "required": [ - "name", - "capacityPerDay" - ], - "type": "object" - }, - "type": "array" - }, - "daysOff": { - "description": "Array of days off for the team member, each with a start and end date in ISO format.", - "items": { - "additionalProperties": false, - "properties": { - "end": { - "description": "End date of the day off in ISO format.", - "type": "string" - }, - "start": { - "description": "Start date of the day off in ISO format.", - "type": "string" - } - }, - "required": [ - "start", - "end" - ], - "type": "object" - }, - "type": "array" - }, - "iterationId": { - "description": "The Iteration Id to update the capacity for.", - "type": "string" - }, - "project": { - "description": "The name or Id of the Azure DevOps project.", - "type": "string" - }, - "team": { - "description": "The name or Id of the Azure DevOps team.", - "type": "string" - }, - "teamMemberId": { - "description": "The team member Id for the specific team member.", - "type": "string" - } - }, - "required": [ - "project", - "team", - "teamMemberId", - "iterationId", - "activities" - ], - "type": "object" - } - }, - { - "name": "work_get_iteration_capacities", - "description": "Get an iteration's capacity for all teams in iteration and project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "iterationId": { - "description": "The Iteration Id to get capacity for.", - "type": "string" - }, - "project": { - "description": "The name or Id of the Azure DevOps project.", - "type": "string" - } - }, - "required": [ - "project", - "iterationId" - ], - "type": "object" - } - }, - { - "name": "pipelines_get_build_definitions", - "description": "Retrieves a list of build definitions for a given project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "builtAfter": { - "description": "Return definitions that have builds after this date", - "format": "date-time", - "type": "string" - }, - "continuationToken": { - "description": "Token for continuing paged results", - "type": "string" - }, - "definitionIds": { - "description": "Array of build definition IDs to filter", - "items": { - "type": "number" - }, - "type": "array" - }, - "includeAllProperties": { - "description": "Whether to include all properties in the results", - "type": "boolean" - }, - "includeLatestBuilds": { - "description": "Whether to include the latest builds for each definition", - "type": "boolean" - }, - "minMetricsTime": { - "description": "Minimum metrics time to filter build definitions", - "format": "date-time", - "type": "string" - }, - "name": { - "description": "Name of the build definition to filter", - "type": "string" - }, - "notBuiltAfter": { - "description": "Return definitions that do not have builds after this date", - "format": "date-time", - "type": "string" - }, - "path": { - "description": "Path of the build definition to filter", - "type": "string" - }, - "processType": { - "description": "Process type to filter build definitions", - "type": "number" - }, - "project": { - "description": "Project ID or name to get build definitions for", - "type": "string" - }, - "queryOrder": { - "description": "Order in which build definitions are returned", - "enum": [ - "None", - "LastModifiedAscending", - "LastModifiedDescending", - "DefinitionNameAscending", - "DefinitionNameDescending" - ], - "type": "string" - }, - "repositoryId": { - "description": "Repository ID to filter build definitions", - "type": "string" - }, - "repositoryType": { - "description": "Type of repository to filter build definitions", - "enum": [ - "TfsGit", - "GitHub", - "BitbucketCloud" - ], - "type": "string" - }, - "taskIdFilter": { - "description": "Task ID to filter build definitions", - "type": "string" - }, - "top": { - "description": "Maximum number of build definitions to return", - "type": "number" - }, - "yamlFilename": { - "description": "YAML filename to filter build definitions", - "type": "string" - } - }, - "required": [ - "project" - ], - "type": "object" - } - }, - { - "name": "pipelines_create_pipeline", - "description": "Creates a pipeline definition with YAML configuration for a given project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "folder": { - "description": "Folder path for the new pipeline. Defaults to '\\' if not specified.", - "type": "string" - }, - "name": { - "description": "Name of the new pipeline.", - "type": "string" - }, - "project": { - "description": "Project ID or name to run the build in.", - "type": "string" - }, - "repositoryConnectionId": { - "description": "The service connection ID for GitHub repositories. Not required for Azure Repos Git.", - "type": "string" - }, - "repositoryId": { - "description": "The ID of the repository.", - "type": "string" - }, - "repositoryName": { - "description": "The name of the repository. In case of GitHub repository, this is the full name (:owner/:repo) - e.g. octocat/Hello-World.", - "type": "string" - }, - "repositoryType": { - "description": "The type of repository where the pipeline's YAML file is located.", - "enum": [ - "Unknown", - "GitHub", - "AzureReposGit", - "GitHubEnterprise", - "BitBucket", - "AzureReposGitHyphenated" - ], - "type": "string" - }, - "yamlPath": { - "description": "The path to the pipeline's YAML file in the repository", - "type": "string" - } - }, - "required": [ - "project", - "name", - "yamlPath", - "repositoryType", - "repositoryName" - ], - "type": "object" - } - }, - { - "name": "pipelines_get_build_definition_revisions", - "description": "Retrieves a list of revisions for a specific build definition.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "definitionId": { - "description": "ID of the build definition to get revisions for", - "type": "number" - }, - "project": { - "description": "Project ID or name to get the build definition revisions for", - "type": "string" - } - }, - "required": [ - "project", - "definitionId" - ], - "type": "object" - } - }, - { - "name": "pipelines_get_builds", - "description": "Retrieves a list of builds for a given project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "branchName": { - "description": "Branch name to filter builds", - "type": "string" - }, - "buildIds": { - "description": "Array of build IDs to retrieve", - "items": { - "type": "number" - }, - "type": "array" - }, - "buildNumber": { - "description": "Build number to filter builds", - "type": "string" - }, - "continuationToken": { - "description": "Token for continuing paged results", - "type": "string" - }, - "definitions": { - "description": "Array of build definition IDs to filter builds", - "items": { - "type": "number" - }, - "type": "array" - }, - "deletedFilter": { - "description": "Filter for deleted builds (see QueryDeletedOption enum)", - "type": "number" - }, - "maxBuildsPerDefinition": { - "description": "Maximum number of builds per definition", - "type": "number" - }, - "maxTime": { - "description": "Maximum finish time to filter builds", - "format": "date-time", - "type": "string" - }, - "minTime": { - "description": "Minimum finish time to filter builds", - "format": "date-time", - "type": "string" - }, - "project": { - "description": "Project ID or name to get builds for", - "type": "string" - }, - "properties": { - "description": "Array of property names to include in the results", - "items": { - "type": "string" - }, - "type": "array" - }, - "queryOrder": { - "default": "QueueTimeDescending", - "description": "Order in which builds are returned", - "enum": [ - "FinishTimeAscending", - "FinishTimeDescending", - "QueueTimeDescending", - "QueueTimeAscending", - "StartTimeDescending", - "StartTimeAscending" - ], - "type": "string" - }, - "queues": { - "description": "Array of queue IDs to filter builds", - "items": { - "type": "number" - }, - "type": "array" - }, - "reasonFilter": { - "description": "Reason filter for the build (see BuildReason enum)", - "type": "number" - }, - "repositoryId": { - "description": "Repository ID to filter builds", - "type": "string" - }, - "repositoryType": { - "description": "Type of repository to filter builds", - "enum": [ - "TfsGit", - "GitHub", - "BitbucketCloud" - ], - "type": "string" - }, - "requestedFor": { - "description": "User ID or name who requested the build", - "type": "string" - }, - "resultFilter": { - "description": "Result filter for the build (see BuildResult enum)", - "type": "number" - }, - "statusFilter": { - "description": "Status filter for the build (see BuildStatus enum)", - "type": "number" - }, - "tagFilters": { - "description": "Array of tags to filter builds", - "items": { - "type": "string" - }, - "type": "array" - }, - "top": { - "description": "Maximum number of builds to return", - "type": "number" - } - }, - "required": [ - "project" - ], - "type": "object" - } - }, - { - "name": "pipelines_get_build_log", - "description": "Retrieves the logs for a specific build.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "buildId": { - "description": "ID of the build to get the log for", - "type": "number" - }, - "project": { - "description": "Project ID or name to get the build log for", - "type": "string" - } - }, - "required": [ - "project", - "buildId" - ], - "type": "object" - } - }, - { - "name": "pipelines_get_build_log_by_id", - "description": "Get a specific build log by log ID.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "buildId": { - "description": "ID of the build to get the log for", - "type": "number" - }, - "endLine": { - "description": "Ending line number for the log content, defaults to the end of the log", - "type": "number" - }, - "logId": { - "description": "ID of the log to retrieve", - "type": "number" - }, - "project": { - "description": "Project ID or name to get the build log for", - "type": "string" - }, - "startLine": { - "description": "Starting line number for the log content, defaults to 0", - "type": "number" - } - }, - "required": [ - "project", - "buildId", - "logId" - ], - "type": "object" - } - }, - { - "name": "pipelines_get_build_changes", - "description": "Get the changes associated with a specific build.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "buildId": { - "description": "ID of the build to get changes for", - "type": "number" - }, - "continuationToken": { - "description": "Continuation token for pagination", - "type": "string" - }, - "includeSourceChange": { - "description": "Whether to include source changes in the results, defaults to false", - "type": "boolean" - }, - "project": { - "description": "Project ID or name to get the build changes for", - "type": "string" - }, - "top": { - "default": 100, - "description": "Number of changes to retrieve, defaults to 100", - "type": "number" - } - }, - "required": [ - "project", - "buildId" - ], - "type": "object" - } - }, - { - "name": "pipelines_get_run", - "description": "Gets a run for a particular pipeline.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "pipelineId": { - "description": "ID of the pipeline to run", - "type": "number" - }, - "project": { - "description": "Project ID or name to run the build in", - "type": "string" - }, - "runId": { - "description": "ID of the run to get", - "type": "number" - } - }, - "required": [ - "project", - "pipelineId", - "runId" - ], - "type": "object" - } - }, - { - "name": "pipelines_list_runs", - "description": "Gets top 10000 runs for a particular pipeline.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "pipelineId": { - "description": "ID of the pipeline to run", - "type": "number" - }, - "project": { - "description": "Project ID or name to run the build in", - "type": "string" - } - }, - "required": [ - "project", - "pipelineId" - ], - "type": "object" - } - }, - { - "name": "pipelines_run_pipeline", - "description": "Starts a new run of a pipeline.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "pipelineId": { - "description": "ID of the pipeline to run", - "type": "number" - }, - "pipelineVersion": { - "description": "Version of the pipeline to run. If not provided, the latest version will be used.", - "type": "number" - }, - "previewRun": { - "description": "If true, returns the final YAML document after parsing templates without creating a new run.", - "type": "boolean" - }, - "project": { - "description": "Project ID or name to run the build in", - "type": "string" - }, - "resources": { - "additionalProperties": false, - "description": "A dictionary of resources to pass to the pipeline.", - "properties": { - "builds": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "version": { - "description": "Version of the build resource.", - "type": "string" - } - }, - "type": "object" - }, - "type": "object" - }, - "containers": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "version": { - "description": "Version of the container resource.", - "type": "string" - } - }, - "type": "object" - }, - "type": "object" - }, - "packages": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "version": { - "description": "Version of the package resource.", - "type": "string" - } - }, - "type": "object" - }, - "type": "object" - }, - "pipelines": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "runId": { - "description": "Id of the source pipeline run that triggered or is referenced by this pipeline run.", - "type": "number" - }, - "version": { - "description": "Version of the source pipeline run.", - "type": "string" - } - }, - "required": [ - "runId" - ], - "type": "object" - }, - "type": "object" - }, - "repositories": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "refName": { - "description": "Reference name, e.g., refs/heads/main.", - "type": "string" - }, - "token": { - "type": "string" - }, - "tokenType": { - "type": "string" - }, - "version": { - "description": "Version of the repository resource, git commit sha.", - "type": "string" - } - }, - "required": [ - "refName" - ], - "type": "object" - }, - "type": "object" - } - }, - "required": [ - "pipelines" - ], - "type": "object" - }, - "stagesToSkip": { - "description": "A list of stages to skip.", - "items": { - "type": "string" - }, - "type": "array" - }, - "templateParameters": { - "additionalProperties": { - "type": "string" - }, - "description": "Custom build parameters as key-value pairs", - "type": "object" - }, - "variables": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "isSecret": { - "type": "boolean" - }, - "value": { - "type": "string" - } - }, - "type": "object" - }, - "description": "A dictionary of variables to pass to the pipeline.", - "type": "object" - }, - "yamlOverride": { - "description": "YAML override for the pipeline run.", - "type": "string" - } - }, - "required": [ - "project", - "pipelineId" - ], - "type": "object" - } - }, - { - "name": "pipelines_get_build_status", - "description": "Fetches the status of a specific build.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "buildId": { - "description": "ID of the build to get the status for", - "type": "number" - }, - "project": { - "description": "Project ID or name to get the build status for", - "type": "string" - } - }, - "required": [ - "project", - "buildId" - ], - "type": "object" - } - }, - { - "name": "pipelines_update_build_stage", - "description": "Updates the stage of a specific build.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "buildId": { - "description": "ID of the build to update", - "type": "number" - }, - "forceRetryAllJobs": { - "default": false, - "description": "Whether to force retry all jobs in the stage.", - "type": "boolean" - }, - "project": { - "description": "Project ID or name to update the build stage for", - "type": "string" - }, - "stageName": { - "description": "Name of the stage to update", - "type": "string" - }, - "status": { - "description": "New status for the stage", - "enum": [ - "Cancel", - "Retry", - "Run" - ], - "type": "string" - } - }, - "required": [ - "project", - "buildId", - "stageName", - "status" - ], - "type": "object" - } - }, - { - "name": "repo_create_pull_request", - "description": "Create a new pull request.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "description": { - "description": "The description of the pull request. Must not be longer than 4000 characters. Optional.", - "maxLength": 4000, - "type": "string" - }, - "forkSourceRepositoryId": { - "description": "The ID of the fork repository that the pull request originates from. Optional, used when creating a pull request from a fork.", - "type": "string" - }, - "isDraft": { - "default": false, - "description": "Indicates whether the pull request is a draft. Defaults to false.", - "type": "boolean" - }, - "labels": { - "description": "Array of label names to add to the pull request after creation.", - "items": { - "type": "string" - }, - "type": "array" - }, - "repositoryId": { - "description": "The ID of the repository where the pull request will be created.", - "type": "string" - }, - "sourceRefName": { - "description": "The source branch name for the pull request, e.g., 'refs/heads/feature-branch'.", - "type": "string" - }, - "targetRefName": { - "description": "The target branch name for the pull request, e.g., 'refs/heads/main'.", - "type": "string" - }, - "title": { - "description": "The title of the pull request.", - "type": "string" - }, - "workItems": { - "description": "Work item IDs to associate with the pull request, space-separated.", - "type": "string" - } - }, - "required": [ - "repositoryId", - "sourceRefName", - "targetRefName", - "title" - ], - "type": "object" - } - }, - { - "name": "repo_create_branch", - "description": "Create a new branch in the repository.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "branchName": { - "description": "The name of the new branch to create, e.g., 'feature-branch'.", - "type": "string" - }, - "repositoryId": { - "description": "The ID of the repository where the branch will be created.", - "type": "string" - }, - "sourceBranchName": { - "default": "main", - "description": "The name of the source branch to create the new branch from. Defaults to 'main'.", - "type": "string" - }, - "sourceCommitId": { - "description": "The commit ID to create the branch from. If not provided, uses the latest commit of the source branch.", - "type": "string" - } - }, - "required": [ - "repositoryId", - "branchName" - ], - "type": "object" - } - }, - { - "name": "repo_update_pull_request", - "description": "Update a Pull Request by ID with specified fields, including setting autocomplete with various completion options.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "autoComplete": { - "description": "Set the pull request to autocomplete when all requirements are met.", - "type": "boolean" - }, - "bypassReason": { - "description": "Reason for bypassing branch policies. When provided, branch policies will be automatically bypassed during autocompletion.", - "type": "string" - }, - "deleteSourceBranch": { - "default": false, - "description": "Whether to delete the source branch when the pull request autocompletes. Defaults to false.", - "type": "boolean" - }, - "description": { - "description": "The new description for the pull request. Must not be longer than 4000 characters.", - "maxLength": 4000, - "type": "string" - }, - "isDraft": { - "description": "Whether the pull request should be a draft.", - "type": "boolean" - }, - "labels": { - "description": "Array of label names to replace existing labels on the pull request. This will remove all current labels and add the specified ones.", - "items": { - "type": "string" - }, - "type": "array" - }, - "mergeStrategy": { - "description": "The merge strategy to use when the pull request autocompletes. Defaults to 'NoFastForward'.", - "enum": [ - "NoFastForward", - "Squash", - "Rebase", - "RebaseMerge" - ], - "type": "string" - }, - "pullRequestId": { - "description": "The ID of the pull request to update.", - "type": "number" - }, - "repositoryId": { - "description": "The ID of the repository where the pull request exists.", - "type": "string" - }, - "status": { - "description": "The new status of the pull request. Can be 'Active' or 'Abandoned'.", - "enum": [ - "Active", - "Abandoned" - ], - "type": "string" - }, - "targetRefName": { - "description": "The new target branch name (e.g., 'refs/heads/main').", - "type": "string" - }, - "title": { - "description": "The new title for the pull request.", - "type": "string" - }, - "transitionWorkItems": { - "default": true, - "description": "Whether to transition associated work items to the next state when the pull request autocompletes. Defaults to true.", - "type": "boolean" - } - }, - "required": [ - "repositoryId", - "pullRequestId" - ], - "type": "object" - } - }, - { - "name": "repo_update_pull_request_reviewers", - "description": "Add or remove reviewers for an existing pull request.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "action": { - "description": "Action to perform on the reviewers. Can be 'add' or 'remove'.", - "enum": [ - "add", - "remove" - ], - "type": "string" - }, - "pullRequestId": { - "description": "The ID of the pull request to update.", - "type": "number" - }, - "repositoryId": { - "description": "The ID of the repository where the pull request exists.", - "type": "string" - }, - "reviewerIds": { - "description": "List of reviewer ids to add or remove from the pull request.", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "repositoryId", - "pullRequestId", - "reviewerIds", - "action" - ], - "type": "object" - } - }, - { - "name": "repo_list_repos_by_project", - "description": "Retrieve a list of repositories for a given project", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "repoNameFilter": { - "description": "Optional filter to search for repositories by name. If provided, only repositories with names containing this string will be returned.", - "type": "string" - }, - "skip": { - "default": 0, - "description": "The number of repositories to skip. Defaults to 0.", - "type": "number" - }, - "top": { - "default": 100, - "description": "The maximum number of repositories to return.", - "type": "number" - } - }, - "required": [ - "project" - ], - "type": "object" - } - }, - { - "name": "repo_list_pull_requests_by_repo_or_project", - "description": "Retrieve a list of pull requests for a given repository. Either repositoryId or project must be provided.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "created_by_me": { - "default": false, - "description": "Filter pull requests created by the current user.", - "type": "boolean" - }, - "created_by_user": { - "description": "Filter pull requests created by a specific user (provide email or unique name). Takes precedence over created_by_me if both are provided.", - "type": "string" - }, - "i_am_reviewer": { - "default": false, - "description": "Filter pull requests where the current user is a reviewer.", - "type": "boolean" - }, - "project": { - "description": "The ID of the project where the pull requests are located.", - "type": "string" - }, - "repositoryId": { - "description": "The ID of the repository where the pull requests are located.", - "type": "string" - }, - "skip": { - "default": 0, - "description": "The number of pull requests to skip.", - "type": "number" - }, - "sourceRefName": { - "description": "Filter pull requests from this source branch (e.g., 'refs/heads/feature-branch').", - "type": "string" - }, - "status": { - "default": "Active", - "description": "Filter pull requests by status. Defaults to 'Active'.", - "enum": [ - "NotSet", - "Active", - "Abandoned", - "Completed", - "All" - ], - "type": "string" - }, - "targetRefName": { - "description": "Filter pull requests into this target branch (e.g., 'refs/heads/main').", - "type": "string" - }, - "top": { - "default": 100, - "description": "The maximum number of pull requests to return.", - "type": "number" - }, - "user_is_reviewer": { - "description": "Filter pull requests where a specific user is a reviewer (provide email or unique name). Takes precedence over i_am_reviewer if both are provided.", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "repo_list_pull_request_threads", - "description": "Retrieve a list of comment threads for a pull request.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "authorDisplayName": { - "description": "Filter threads by the display name of the thread author (first comment author). Case-insensitive partial matching.", - "type": "string" - }, - "authorEmail": { - "description": "Filter threads by the email of the thread author (first comment author).", - "type": "string" - }, - "baseIteration": { - "description": "The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration.", - "type": "number" - }, - "fullResponse": { - "default": false, - "description": "Return full thread JSON response instead of trimmed data.", - "type": "boolean" - }, - "iteration": { - "description": "The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration.", - "type": "number" - }, - "project": { - "description": "Project ID or project name (optional)", - "type": "string" - }, - "pullRequestId": { - "description": "The ID of the pull request for which to retrieve threads.", - "type": "number" - }, - "repositoryId": { - "description": "The ID of the repository where the pull request is located.", - "type": "string" - }, - "skip": { - "default": 0, - "description": "The number of threads to skip after filtering.", - "type": "number" - }, - "status": { - "description": "Filter threads by status. If not specified, returns threads of all statuses.", - "enum": [ - "Unknown", - "Active", - "Fixed", - "WontFix", - "Closed", - "ByDesign", - "Pending" - ], - "type": "string" - }, - "top": { - "default": 100, - "description": "The maximum number of threads to return after filtering.", - "type": "number" - } - }, - "required": [ - "repositoryId", - "pullRequestId" - ], - "type": "object" - } - }, - { - "name": "repo_list_pull_request_thread_comments", - "description": "Retrieve a list of comments in a pull request thread.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "fullResponse": { - "default": false, - "description": "Return full comment JSON response instead of trimmed data.", - "type": "boolean" - }, - "project": { - "description": "Project ID or project name (optional)", - "type": "string" - }, - "pullRequestId": { - "description": "The ID of the pull request for which to retrieve thread comments.", - "type": "number" - }, - "repositoryId": { - "description": "The ID of the repository where the pull request is located.", - "type": "string" - }, - "skip": { - "default": 0, - "description": "The number of comments to skip.", - "type": "number" - }, - "threadId": { - "description": "The ID of the thread for which to retrieve comments.", - "type": "number" - }, - "top": { - "default": 100, - "description": "The maximum number of comments to return.", - "type": "number" - } - }, - "required": [ - "repositoryId", - "pullRequestId", - "threadId" - ], - "type": "object" - } - }, - { - "name": "repo_list_branches_by_repo", - "description": "Retrieve a list of branches for a given repository.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "filterContains": { - "description": "Filter to find branches that contain this string in their name.", - "type": "string" - }, - "repositoryId": { - "description": "The ID of the repository where the branches are located.", - "type": "string" - }, - "top": { - "default": 100, - "description": "The maximum number of branches to return. Defaults to 100.", - "type": "number" - } - }, - "required": [ - "repositoryId" - ], - "type": "object" - } - }, - { - "name": "repo_list_my_branches_by_repo", - "description": "Retrieve a list of my branches for a given repository Id.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "filterContains": { - "description": "Filter to find branches that contain this string in their name.", - "type": "string" - }, - "repositoryId": { - "description": "The ID of the repository where the branches are located.", - "type": "string" - }, - "top": { - "default": 100, - "description": "The maximum number of branches to return.", - "type": "number" - } - }, - "required": [ - "repositoryId" - ], - "type": "object" - } - }, - { - "name": "repo_get_repo_by_name_or_id", - "description": "Get the repository by project and repository name or ID.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "Project name or ID where the repository is located.", - "type": "string" - }, - "repositoryNameOrId": { - "description": "Repository name or ID.", - "type": "string" - } - }, - "required": [ - "project", - "repositoryNameOrId" - ], - "type": "object" - } - }, - { - "name": "repo_get_branch_by_name", - "description": "Get a branch by its name.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "branchName": { - "description": "The name of the branch to retrieve, e.g., 'main' or 'feature-branch'.", - "type": "string" - }, - "repositoryId": { - "description": "The ID of the repository where the branch is located.", - "type": "string" - } - }, - "required": [ - "repositoryId", - "branchName" - ], - "type": "object" - } - }, - { - "name": "repo_get_pull_request_by_id", - "description": "Get a pull request by its ID.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "includeLabels": { - "default": false, - "description": "Whether to include a summary of labels in the response.", - "type": "boolean" - }, - "includeWorkItemRefs": { - "default": false, - "description": "Whether to reference work items associated with the pull request.", - "type": "boolean" - }, - "pullRequestId": { - "description": "The ID of the pull request to retrieve.", - "type": "number" - }, - "repositoryId": { - "description": "The ID of the repository where the pull request is located.", - "type": "string" - } - }, - "required": [ - "repositoryId", - "pullRequestId" - ], - "type": "object" - } - }, - { - "name": "repo_reply_to_comment", - "description": "Replies to a specific comment on a pull request.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "content": { - "description": "The content of the comment to be added.", - "type": "string" - }, - "fullResponse": { - "default": false, - "description": "Return full comment JSON response instead of a simple confirmation message.", - "type": "boolean" - }, - "project": { - "description": "Project ID or project name (optional)", - "type": "string" - }, - "pullRequestId": { - "description": "The ID of the pull request where the comment thread exists.", - "type": "number" - }, - "repositoryId": { - "description": "The ID of the repository where the pull request is located.", - "type": "string" - }, - "threadId": { - "description": "The ID of the thread to which the comment will be added.", - "type": "number" - } - }, - "required": [ - "repositoryId", - "pullRequestId", - "threadId", - "content" - ], - "type": "object" - } - }, - { - "name": "repo_create_pull_request_thread", - "description": "Creates a new comment thread on a pull request.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "content": { - "description": "The content of the comment to be added.", - "type": "string" - }, - "filePath": { - "description": "The path of the file where the comment thread will be created. (optional)", - "type": "string" - }, - "project": { - "description": "Project ID or project name (optional)", - "type": "string" - }, - "pullRequestId": { - "description": "The ID of the pull request where the comment thread exists.", - "type": "number" - }, - "repositoryId": { - "description": "The ID of the repository where the pull request is located.", - "type": "string" - }, - "rightFileEndLine": { - "description": "Position of last character of the thread's span in right file. The line number of a thread's position. Starts at 1. Must be set if rightFileStartLine is also specified. (optional)", - "type": "number" - }, - "rightFileEndOffset": { - "description": "Position of last character of the thread's span in right file. The character offset of a thread's position inside of a line. Must be set if rightFileEndLine is also specified. (optional)", - "type": "number" - }, - "rightFileStartLine": { - "description": "Position of first character of the thread's span in right file. The line number of a thread's position. Starts at 1. (optional)", - "type": "number" - }, - "rightFileStartOffset": { - "description": "Position of first character of the thread's span in right file. The line number of a thread's position. The character offset of a thread's position inside of a line. Starts at 1. Must be set if rightFileStartLine is also specified. (optional)", - "type": "number" - }, - "status": { - "default": "Active", - "description": "The status of the comment thread. Defaults to 'Active'.", - "enum": [ - "Unknown", - "Active", - "Fixed", - "WontFix", - "Closed", - "ByDesign", - "Pending" - ], - "type": "string" - } - }, - "required": [ - "repositoryId", - "pullRequestId", - "content" - ], - "type": "object" - } - }, - { - "name": "repo_update_pull_request_thread", - "description": "Updates an existing comment thread on a pull request.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "Project ID or project name (optional)", - "type": "string" - }, - "pullRequestId": { - "description": "The ID of the pull request where the comment thread exists.", - "type": "number" - }, - "repositoryId": { - "description": "The ID of the repository where the pull request is located.", - "type": "string" - }, - "status": { - "description": "The new status for the comment thread.", - "enum": [ - "Unknown", - "Active", - "Fixed", - "WontFix", - "Closed", - "ByDesign", - "Pending" - ], - "type": "string" - }, - "threadId": { - "description": "The ID of the thread to update.", - "type": "number" - } - }, - "required": [ - "repositoryId", - "pullRequestId", - "threadId" - ], - "type": "object" - } - }, - { - "name": "repo_search_commits", - "description": "Search for commits in a repository with comprehensive filtering capabilities. Supports searching by description/comment text, time range, author, committer, specific commit IDs, and more. This is the unified tool for all commit search operations.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "author": { - "description": "Filter commits by author email or display name", - "type": "string" - }, - "authorEmail": { - "description": "Filter commits by exact author email address", - "type": "string" - }, - "commitIds": { - "description": "Array of specific commit IDs to retrieve. When provided, other filters are ignored except top/skip.", - "items": { - "type": "string" - }, - "type": "array" - }, - "committer": { - "description": "Filter commits by committer email or display name", - "type": "string" - }, - "committerEmail": { - "description": "Filter commits by exact committer email address", - "type": "string" - }, - "fromCommit": { - "description": "Starting commit ID", - "type": "string" - }, - "fromDate": { - "description": "Filter commits from this date (ISO 8601 format, e.g., '2024-01-01T00:00:00Z')", - "type": "string" - }, - "historySimplificationMode": { - "description": "How to simplify the commit history", - "enum": [ - "FirstParent", - "SimplifyMerges", - "FullHistory", - "FullHistorySimplifyMerges" - ], - "type": "string" - }, - "includeLinks": { - "default": false, - "description": "Include commit links", - "type": "boolean" - }, - "includeWorkItems": { - "default": false, - "description": "Include associated work items", - "type": "boolean" - }, - "project": { - "description": "Project name or ID", - "type": "string" - }, - "repository": { - "description": "Repository name or ID", - "type": "string" - }, - "searchText": { - "description": "Search text to filter commits by description/comment. Supports partial matching.", - "type": "string" - }, - "skip": { - "default": 0, - "description": "Number of commits to skip", - "type": "number" - }, - "toCommit": { - "description": "Ending commit ID", - "type": "string" - }, - "toDate": { - "description": "Filter commits to this date (ISO 8601 format, e.g., '2024-12-31T23:59:59Z')", - "type": "string" - }, - "top": { - "default": 10, - "description": "Maximum number of commits to return", - "type": "number" - }, - "version": { - "description": "The name of the branch, tag or commit to filter commits by", - "type": "string" - }, - "versionType": { - "default": "Branch", - "description": "The meaning of the version parameter, e.g., branch, tag or commit", - "enum": [ - "Branch", - "Tag", - "Commit" - ], - "type": "string" - } - }, - "required": [ - "project", - "repository" - ], - "type": "object" - } - }, - { - "name": "repo_list_pull_requests_by_commits", - "description": "Lists pull requests by commit IDs to find which pull requests contain specific commits", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "commits": { - "description": "Array of commit IDs to query for", - "items": { - "type": "string" - }, - "type": "array" - }, - "project": { - "description": "Project name or ID", - "type": "string" - }, - "queryType": { - "default": "LastMergeCommit", - "description": "Type of query to perform", - "enum": [ - "NotSet", - "LastMergeCommit", - "Commit" - ], - "type": "string" - }, - "repository": { - "description": "Repository name or ID", - "type": "string" - } - }, - "required": [ - "project", - "repository", - "commits" - ], - "type": "object" - } - }, - { - "name": "wit_list_backlogs", - "description": "Receive a list of backlogs for a given project and team.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "team": { - "description": "The name or ID of the Azure DevOps team.", - "type": "string" - } - }, - "required": [ - "project", - "team" - ], - "type": "object" - } - }, - { - "name": "wit_list_backlog_work_items", - "description": "Retrieve a list of backlogs of for a given project, team, and backlog category", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "backlogId": { - "description": "The ID of the backlog category to retrieve work items from.", - "type": "string" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "team": { - "description": "The name or ID of the Azure DevOps team.", - "type": "string" - } - }, - "required": [ - "project", - "team", - "backlogId" - ], - "type": "object" - } - }, - { - "name": "wit_my_work_items", - "description": "Retrieve a list of work items relevent to the authenticated user.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "includeCompleted": { - "default": false, - "description": "Whether to include completed work items. Defaults to false.", - "type": "boolean" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "top": { - "default": 50, - "description": "The maximum number of work items to return. Defaults to 50.", - "type": "number" - }, - "type": { - "default": "assignedtome", - "description": "The type of work items to retrieve. Defaults to 'assignedtome'.", - "enum": [ - "assignedtome", - "myactivity" - ], - "type": "string" - } - }, - "required": [ - "project" - ], - "type": "object" - } - }, - { - "name": "wit_get_work_items_batch_by_ids", - "description": "Retrieve list of work items by IDs in batch.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "fields": { - "description": "Optional list of fields to include in the response. If not provided, a hardcoded default set of fields will be used.", - "items": { - "type": "string" - }, - "type": "array" - }, - "ids": { - "description": "The IDs of the work items to retrieve.", - "items": { - "type": "number" - }, - "type": "array" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - } - }, - "required": [ - "project", - "ids" - ], - "type": "object" - } - }, - { - "name": "wit_get_work_item", - "description": "Get a single work item by ID.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "asOf": { - "description": "Optional date string to retrieve the work item as of a specific time. If not provided, the current state will be returned.", - "format": "date-time", - "type": "string" - }, - "expand": { - "description": "Expand options include 'all', 'fields', 'links', 'none', and 'relations'. Relations can be used to get child workitems. Defaults to 'none'.", - "enum": [ - "all", - "fields", - "links", - "none", - "relations" - ], - "type": "string" - }, - "fields": { - "description": "Optional list of fields to include in the response. If not provided, all fields will be returned.", - "items": { - "type": "string" - }, - "type": "array" - }, - "id": { - "description": "The ID of the work item to retrieve.", - "type": "number" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - } - }, - "required": [ - "id", - "project" - ], - "type": "object" - } - }, - { - "name": "wit_list_work_item_comments", - "description": "Retrieve list of comments for a work item by ID.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "top": { - "default": 50, - "description": "Optional number of comments to retrieve. Defaults to all comments.", - "type": "number" - }, - "workItemId": { - "description": "The ID of the work item to retrieve comments for.", - "type": "number" - } - }, - "required": [ - "project", - "workItemId" - ], - "type": "object" - } - }, - { - "name": "wit_add_work_item_comment", - "description": "Add comment to a work item by ID.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "comment": { - "description": "The text of the comment to add to the work item.", - "type": "string" - }, - "format": { - "default": "html", - "enum": [ - "markdown", - "html" - ], - "type": "string" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "workItemId": { - "description": "The ID of the work item to add a comment to.", - "type": "number" - } - }, - "required": [ - "project", - "workItemId", - "comment" - ], - "type": "object" - } - }, - { - "name": "wit_list_work_item_revisions", - "description": "Retrieve list of revisions for a work item by ID.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "expand": { - "default": "None", - "description": "Optional expand parameter to include additional details. Defaults to 'None'.", - "enum": [ - "None", - "Relations", - "Fields", - "Links", - "All" - ], - "type": "string" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "skip": { - "description": "Optional number of revisions to skip for pagination. Defaults to 0.", - "type": "number" - }, - "top": { - "default": 50, - "description": "Optional number of revisions to retrieve. If not provided, all revisions will be returned.", - "type": "number" - }, - "workItemId": { - "description": "The ID of the work item to retrieve revisions for.", - "type": "number" - } - }, - "required": [ - "project", - "workItemId" - ], - "type": "object" - } - }, - { - "name": "wit_add_child_work_items", - "description": "Create one or many child work items from a parent by work item type and parent id.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "items": { - "items": { - "additionalProperties": false, - "properties": { - "areaPath": { - "description": "Optional area path for the child work item.", - "type": "string" - }, - "description": { - "description": "The description of the child work item.", - "type": "string" - }, - "format": { - "default": "Html", - "description": "Format for the description on the child work item, e.g., 'Markdown', 'Html'. Defaults to 'Html'.", - "enum": [ - "Markdown", - "Html" - ], - "type": "string" - }, - "iterationPath": { - "description": "Optional iteration path for the child work item.", - "type": "string" - }, - "title": { - "description": "The title of the child work item.", - "type": "string" - } - }, - "required": [ - "title", - "description" - ], - "type": "object" - }, - "type": "array" - }, - "parentId": { - "description": "The ID of the parent work item to create a child work item under.", - "type": "number" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "workItemType": { - "description": "The type of the child work item to create.", - "type": "string" - } - }, - "required": [ - "parentId", - "project", - "workItemType", - "items" - ], - "type": "object" - } - }, - { - "name": "wit_link_work_item_to_pull_request", - "description": "Link a single work item to an existing pull request.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "projectId": { - "description": "The project ID of the Azure DevOps project (note: project name is not valid).", - "type": "string" - }, - "pullRequestId": { - "description": "The ID of the pull request to link to.", - "type": "number" - }, - "pullRequestProjectId": { - "description": "The project ID containing the pull request. If not provided, defaults to the work item's project ID (for same-project linking).", - "type": "string" - }, - "repositoryId": { - "description": "The ID of the repository containing the pull request. Do not use the repository name here, use the ID instead.", - "type": "string" - }, - "workItemId": { - "description": "The ID of the work item to link to the pull request.", - "type": "number" - } - }, - "required": [ - "projectId", - "repositoryId", - "pullRequestId", - "workItemId" - ], - "type": "object" - } - }, - { - "name": "wit_get_work_items_for_iteration", - "description": "Retrieve a list of work items for a specified iteration.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "iterationId": { - "description": "The ID of the iteration to retrieve work items for.", - "type": "string" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "team": { - "description": "The name or ID of the Azure DevOps team. If not provided, the default team will be used.", - "type": "string" - } - }, - "required": [ - "project", - "iterationId" - ], - "type": "object" - } - }, - { - "name": "wit_update_work_item", - "description": "Update a work item by ID with specified fields.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "id": { - "description": "The ID of the work item to update.", - "type": "number" - }, - "updates": { - "description": "An array of field updates to apply to the work item.", - "items": { - "additionalProperties": false, - "properties": { - "op": { - "default": "add", - "description": "The operation to perform on the field.", - "type": "string" - }, - "path": { - "description": "The path of the field to update, e.g., '/fields/System.Title'.", - "type": "string" - }, - "value": { - "description": "The new value for the field. This is required for 'Add' and 'Replace' operations, and should be omitted for 'Remove' operations.", - "type": "string" - } - }, - "required": [ - "path", - "value" - ], - "type": "object" - }, - "type": "array" - } - }, - "required": [ - "id", - "updates" - ], - "type": "object" - } - }, - { - "name": "wit_get_work_item_type", - "description": "Get a specific work item type.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "workItemType": { - "description": "The name of the work item type to retrieve.", - "type": "string" - } - }, - "required": [ - "project", - "workItemType" - ], - "type": "object" - } - }, - { - "name": "wit_create_work_item", - "description": "Create a new work item in a specified project and work item type.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "fields": { - "description": "A record of field names and values to set on the new work item. Each fild is the field name and each value is the corresponding value to set for that field.", - "items": { - "additionalProperties": false, - "properties": { - "format": { - "description": "the format of the field value, e.g., 'Html', 'Markdown'. Optional, defaults to 'Html'.", - "enum": [ - "Html", - "Markdown" - ], - "type": "string" - }, - "name": { - "description": "The name of the field, e.g., 'System.Title'.", - "type": "string" - }, - "value": { - "description": "The value of the field.", - "type": "string" - } - }, - "required": [ - "name", - "value" - ], - "type": "object" - }, - "type": "array" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "workItemType": { - "description": "The type of work item to create, e.g., 'Task', 'Bug', etc.", - "type": "string" - } - }, - "required": [ - "project", - "workItemType", - "fields" - ], - "type": "object" - } - }, - { - "name": "wit_get_query", - "description": "Get a query by its ID or path.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "depth": { - "default": 0, - "description": "Optional depth parameter to specify how deep to expand the query. Defaults to 0.", - "type": "number" - }, - "expand": { - "description": "Optional expand parameter to include additional details in the response. Defaults to 'None'.", - "enum": [ - "None", - "Wiql", - "Clauses", - "All", - "Minimal" - ], - "type": "string" - }, - "includeDeleted": { - "default": false, - "description": "Whether to include deleted items in the query results. Defaults to false.", - "type": "boolean" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "query": { - "description": "The ID or path of the query to retrieve.", - "type": "string" - }, - "useIsoDateFormat": { - "default": false, - "description": "Whether to use ISO date format in the response. Defaults to false.", - "type": "boolean" - } - }, - "required": [ - "project", - "query" - ], - "type": "object" - } - }, - { - "name": "wit_get_query_results_by_id", - "description": "Retrieve the results of a work item query given the query ID. Supports full or IDs-only response types.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "id": { - "description": "The ID of the query to retrieve results for.", - "type": "string" - }, - "project": { - "description": "The name or ID of the Azure DevOps project. If not provided, the default project will be used.", - "type": "string" - }, - "responseType": { - "default": "full", - "description": "Response type: 'full' returns complete query results (default), 'ids' returns only work item IDs for reduced payload size.", - "enum": [ - "full", - "ids" - ], - "type": "string" - }, - "team": { - "description": "The name or ID of the Azure DevOps team. If not provided, the default team will be used.", - "type": "string" - }, - "timePrecision": { - "description": "Whether to include time precision in the results. Defaults to false.", - "type": "boolean" - }, - "top": { - "default": 50, - "description": "The maximum number of results to return. Defaults to 50.", - "type": "number" - } - }, - "required": [ - "id" - ], - "type": "object" - } - }, - { - "name": "wit_update_work_items_batch", - "description": "Update work items in batch", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "updates": { - "description": "An array of updates to apply to work items. Each update should include the operation (op), work item ID (id), field path (path), and new value (value).", - "items": { - "additionalProperties": false, - "properties": { - "format": { - "description": "The format of the field value. Only to be used for large text fields. e.g., 'Html', 'Markdown'. Optional, defaults to 'Html'.", - "enum": [ - "Html", - "Markdown" - ], - "type": "string" - }, - "id": { - "description": "The ID of the work item to update.", - "type": "number" - }, - "op": { - "default": "Add", - "description": "The operation to perform on the field.", - "enum": [ - "Add", - "Replace", - "Remove" - ], - "type": "string" - }, - "path": { - "description": "The path of the field to update, e.g., '/fields/System.Title'.", - "type": "string" - }, - "value": { - "description": "The new value for the field. This is required for 'add' and 'replace' operations, and should be omitted for 'remove' operations.", - "type": "string" - } - }, - "required": [ - "id", - "path", - "value" - ], - "type": "object" - }, - "type": "array" - } - }, - "required": [ - "updates" - ], - "type": "object" - } - }, - { - "name": "wit_work_items_link", - "description": "Link work items together in batch.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "updates": { - "items": { - "additionalProperties": false, - "properties": { - "comment": { - "description": "Optional comment to include with the link. This can be used to provide additional context for the link being created.", - "type": "string" - }, - "id": { - "description": "The ID of the work item to update.", - "type": "number" - }, - "linkToId": { - "description": "The ID of the work item to link to.", - "type": "number" - }, - "type": { - "default": "related", - "description": "Type of link to create between the work items. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', and 'affected by'. Defaults to 'related'.", - "enum": [ - "parent", - "child", - "duplicate", - "duplicate of", - "related", - "successor", - "predecessor", - "tested by", - "tests", - "affects", - "affected by" - ], - "type": "string" - } - }, - "required": [ - "id", - "linkToId" - ], - "type": "object" - }, - "type": "array" - } - }, - "required": [ - "project", - "updates" - ], - "type": "object" - } - }, - { - "name": "wit_work_item_unlink", - "description": "Remove one or many links from a single work item", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "id": { - "description": "The ID of the work item to remove the links from.", - "type": "number" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "type": { - "default": "related", - "description": "Type of link to remove. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', 'affected by', and 'artifact'. Defaults to 'related'.", - "enum": [ - "parent", - "child", - "duplicate", - "duplicate of", - "related", - "successor", - "predecessor", - "tested by", - "tests", - "affects", - "affected by", - "artifact" - ], - "type": "string" - }, - "url": { - "description": "Optional URL to match for the link to remove. If not provided, all links of the specified type will be removed.", - "type": "string" - } - }, - "required": [ - "project", - "id" - ], - "type": "object" - } - }, - { - "name": "wit_add_artifact_link", - "description": "Add artifact links (repository, branch, commit, builds) to work items. You can either provide the full vstfs URI or the individual components to build it automatically.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "artifactUri": { - "description": "The complete VSTFS URI of the artifact to link. If provided, individual component parameters are ignored.", - "type": "string" - }, - "branchName": { - "description": "The branch name (e.g., 'main'). Required when linkType is 'Branch'.", - "type": "string" - }, - "buildId": { - "description": "The build ID. Required when linkType is 'Build', 'Found in build', or 'Integrated in build'.", - "type": "number" - }, - "comment": { - "description": "Comment to include with the artifact link.", - "type": "string" - }, - "commitId": { - "description": "The commit SHA hash. Required when linkType is 'Fixed in Commit'.", - "type": "string" - }, - "linkType": { - "default": "Branch", - "description": "Type of artifact link, defaults to 'Branch'. This determines both the link type and how to build the VSTFS URI from individual components.", - "enum": [ - "Branch", - "Build", - "Fixed in Changeset", - "Fixed in Commit", - "Found in build", - "Integrated in build", - "Model Link", - "Pull Request", - "Related Workitem", - "Result Attachment", - "Source Code File", - "Tag", - "Test Result", - "Wiki" - ], - "type": "string" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "projectId": { - "description": "The project ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided.", - "type": "string" - }, - "pullRequestId": { - "description": "The pull request ID. Required when linkType is 'Pull Request'.", - "type": "number" - }, - "repositoryId": { - "description": "The repository ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided.", - "type": "string" - }, - "workItemId": { - "description": "The ID of the work item to add the artifact link to.", - "type": "number" - } - }, - "required": [ - "workItemId", - "project" - ], - "type": "object" - } - }, - { - "name": "wiki_get_wiki", - "description": "Get the wiki by wikiIdentifier", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "The project name or ID where the wiki is located. If not provided, the default project will be used.", - "type": "string" - }, - "wikiIdentifier": { - "description": "The unique identifier of the wiki.", - "type": "string" - } - }, - "required": [ - "wikiIdentifier" - ], - "type": "object" - } - }, - { - "name": "wiki_list_wikis", - "description": "Retrieve a list of wikis for an organization or project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project": { - "description": "The project name or ID to filter wikis. If not provided, all wikis in the organization will be returned.", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "wiki_list_pages", - "description": "Retrieve a list of wiki pages for a specific wiki and project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "continuationToken": { - "description": "Token for pagination to retrieve the next set of pages.", - "type": "string" - }, - "pageViewsForDays": { - "description": "Number of days to retrieve page views for. If not specified, page views are not included.", - "type": "number" - }, - "project": { - "description": "The project name or ID where the wiki is located.", - "type": "string" - }, - "top": { - "default": 20, - "description": "The maximum number of pages to return. Defaults to 20.", - "type": "number" - }, - "wikiIdentifier": { - "description": "The unique identifier of the wiki.", - "type": "string" - } - }, - "required": [ - "wikiIdentifier", - "project" - ], - "type": "object" - } - }, - { - "name": "wiki_get_page", - "description": "Retrieve wiki page metadata by path. This tool does not return page content.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "path": { - "description": "The path of the wiki page (e.g., '/Home' or '/Documentation/Setup').", - "type": "string" - }, - "project": { - "description": "The project name or ID where the wiki is located.", - "type": "string" - }, - "recursionLevel": { - "description": "Recursion level for subpages. 'None' returns only the specified page. 'OneLevel' includes direct children. 'Full' includes all descendants.", - "enum": [ - "None", - "OneLevel", - "OneLevelPlusNestedEmptyFolders", - "Full" - ], - "type": "string" - }, - "wikiIdentifier": { - "description": "The unique identifier of the wiki.", - "type": "string" - } - }, - "required": [ - "wikiIdentifier", - "project", - "path" - ], - "type": "object" - } - }, - { - "name": "wiki_get_page_content", - "description": "Retrieve wiki page content. Provide either a 'url' parameter OR the combination of 'wikiIdentifier' and 'project' parameters.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "path": { - "description": "The path of the wiki page to retrieve content for. Optional, defaults to root page if not provided.", - "type": "string" - }, - "project": { - "description": "The project name or ID where the wiki is located. Required if url is not provided.", - "type": "string" - }, - "url": { - "description": "The full URL of the wiki page to retrieve content for. If provided, wikiIdentifier, project, and path are ignored. Supported patterns: https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}?pagePath=%2FMy%20Page and https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}/{pageId}/Page-Title", - "type": "string" - }, - "wikiIdentifier": { - "description": "The unique identifier of the wiki. Required if url is not provided.", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "wiki_create_or_update_page", - "description": "Create or update a wiki page with content.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "branch": { - "default": "wikiMaster", - "description": "The branch name for the wiki repository. Defaults to 'wikiMaster' which is the default branch for Azure DevOps wikis.", - "type": "string" - }, - "content": { - "description": "The content of the wiki page in markdown format.", - "type": "string" - }, - "etag": { - "description": "ETag for editing existing pages (optional, will be fetched if not provided).", - "type": "string" - }, - "path": { - "description": "The path of the wiki page (e.g., '/Home' or '/Documentation/Setup').", - "type": "string" - }, - "project": { - "description": "The project name or ID where the wiki is located. If not provided, the default project will be used.", - "type": "string" - }, - "wikiIdentifier": { - "description": "The unique identifier or name of the wiki.", - "type": "string" - } - }, - "required": [ - "wikiIdentifier", - "path", - "content" - ], - "type": "object" - } - }, - { - "name": "testplan_list_test_plans", - "description": "Retrieve a paginated list of test plans from an Azure DevOps project. Allows filtering for active plans and toggling detailed information.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "continuationToken": { - "description": "Token to continue fetching test plans from a previous request.", - "type": "string" - }, - "filterActivePlans": { - "default": true, - "description": "Filter to include only active test plans. Defaults to true.", - "type": "boolean" - }, - "includePlanDetails": { - "default": false, - "description": "Include detailed information about each test plan.", - "type": "boolean" - }, - "project": { - "description": "The unique identifier (ID or name) of the Azure DevOps project.", - "type": "string" - } - }, - "required": [ - "project" - ], - "type": "object" - } - }, - { - "name": "testplan_create_test_plan", - "description": "Creates a new test plan in the project.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "areaPath": { - "description": "The area path for the test plan", - "type": "string" - }, - "description": { - "description": "The description of the test plan", - "type": "string" - }, - "endDate": { - "description": "The end date of the test plan", - "type": "string" - }, - "iteration": { - "description": "The iteration path for the test plan", - "type": "string" - }, - "name": { - "description": "The name of the test plan to be created.", - "type": "string" - }, - "project": { - "description": "The unique identifier (ID or name) of the Azure DevOps project where the test plan will be created.", - "type": "string" - }, - "startDate": { - "description": "The start date of the test plan", - "type": "string" - } - }, - "required": [ - "project", - "name", - "iteration" - ], - "type": "object" - } - }, - { - "name": "testplan_create_test_suite", - "description": "Creates a new test suite in a test plan.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "name": { - "description": "Name of the child test suite", - "type": "string" - }, - "parentSuiteId": { - "description": "ID of the parent suite under which the new suite will be created, if not given by user this can be id of a root suite of the test plan", - "type": "number" - }, - "planId": { - "description": "ID of the test plan that contains the suites", - "type": "number" - }, - "project": { - "description": "Project ID or project name", - "type": "string" - } - }, - "required": [ - "project", - "planId", - "parentSuiteId", - "name" - ], - "type": "object" - } - }, - { - "name": "testplan_add_test_cases_to_suite", - "description": "Adds existing test cases to a test suite.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "planId": { - "description": "The ID of the test plan.", - "type": "number" - }, - "project": { - "description": "The unique identifier (ID or name) of the Azure DevOps project.", - "type": "string" - }, - "suiteId": { - "description": "The ID of the test suite.", - "type": "number" - }, - "testCaseIds": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "description": "The ID(s) of the test case(s) to add. " - } - }, - "required": [ - "project", - "planId", - "suiteId", - "testCaseIds" - ], - "type": "object" - } - }, - { - "name": "testplan_create_test_case", - "description": "Creates a new test case work item.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "areaPath": { - "description": "The area path for the test case.", - "type": "string" - }, - "iterationPath": { - "description": "The iteration path for the test case.", - "type": "string" - }, - "priority": { - "description": "The priority of the test case.", - "type": "number" - }, - "project": { - "description": "The unique identifier (ID or name) of the Azure DevOps project.", - "type": "string" - }, - "steps": { - "description": "The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result.", - "type": "string" - }, - "testsWorkItemId": { - "description": "Optional work item id that will be set as a Microsoft.VSTS.Common.TestedBy-Reverse link to the test case.", - "type": "number" - }, - "title": { - "description": "The title of the test case.", - "type": "string" - } - }, - "required": [ - "project", - "title" - ], - "type": "object" - } - }, - { - "name": "testplan_update_test_case_steps", - "description": "Update an existing test case work item.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "id": { - "description": "The ID of the test case work item to update.", - "type": "number" - }, - "steps": { - "description": "The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result.", - "type": "string" - } - }, - "required": [ - "id", - "steps" - ], - "type": "object" - } - }, - { - "name": "testplan_list_test_cases", - "description": "Gets a list of test cases in the test plan.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "planid": { - "description": "The ID of the test plan.", - "type": "number" - }, - "project": { - "description": "The unique identifier (ID or name) of the Azure DevOps project.", - "type": "string" - }, - "suiteid": { - "description": "The ID of the test suite.", - "type": "number" - } - }, - "required": [ - "project", - "planid", - "suiteid" - ], - "type": "object" - } - }, - { - "name": "testplan_show_test_results_from_build_id", - "description": "Gets a list of test results for a given project and build ID.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "buildid": { - "description": "The ID of the build.", - "type": "number" - }, - "project": { - "description": "The unique identifier (ID or name) of the Azure DevOps project.", - "type": "string" - } - }, - "required": [ - "project", - "buildid" - ], - "type": "object" - } - }, - { - "name": "testplan_list_test_suites", - "description": "Retrieve a paginated list of test suites from an Azure DevOps project and Test Plan Id.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "continuationToken": { - "description": "Token to continue fetching test plans from a previous request.", - "type": "string" - }, - "planId": { - "description": "The ID of the test plan.", - "type": "number" - }, - "project": { - "description": "The unique identifier (ID or name) of the Azure DevOps project.", - "type": "string" - } - }, - "required": [ - "project", - "planId" - ], - "type": "object" - } - }, - { - "name": "search_code", - "description": "Search Azure DevOps Repositories for a given search text", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "branch": { - "description": "Filter by branches", - "items": { - "type": "string" - }, - "type": "array" - }, - "includeFacets": { - "default": false, - "description": "Include facets in the search results", - "type": "boolean" - }, - "path": { - "description": "Filter by paths", - "items": { - "type": "string" - }, - "type": "array" - }, - "project": { - "description": "Filter by projects", - "items": { - "type": "string" - }, - "type": "array" - }, - "repository": { - "description": "Filter by repositories", - "items": { - "type": "string" - }, - "type": "array" - }, - "searchText": { - "description": "Keywords to search for in code repositories", - "type": "string" - }, - "skip": { - "default": 0, - "description": "Number of results to skip", - "type": "number" - }, - "top": { - "default": 5, - "description": "Maximum number of results to return", - "type": "number" - } - }, - "required": [ - "searchText" - ], - "type": "object" - } - }, - { - "name": "search_wiki", - "description": "Search Azure DevOps Wiki for a given search text", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "includeFacets": { - "default": false, - "description": "Include facets in the search results", - "type": "boolean" - }, - "project": { - "description": "Filter by projects", - "items": { - "type": "string" - }, - "type": "array" - }, - "searchText": { - "description": "Keywords to search for wiki pages", - "type": "string" - }, - "skip": { - "default": 0, - "description": "Number of results to skip", - "type": "number" - }, - "top": { - "default": 10, - "description": "Maximum number of results to return", - "type": "number" - }, - "wiki": { - "description": "Filter by wiki names", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "searchText" - ], - "type": "object" - } - }, - { - "name": "search_workitem", - "description": "Get Azure DevOps Work Item search results for a given search text", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "areaPath": { - "description": "Filter by area paths", - "items": { - "type": "string" - }, - "type": "array" - }, - "assignedTo": { - "description": "Filter by assigned to users", - "items": { - "type": "string" - }, - "type": "array" - }, - "includeFacets": { - "default": false, - "description": "Include facets in the search results", - "type": "boolean" - }, - "project": { - "description": "Filter by projects", - "items": { - "type": "string" - }, - "type": "array" - }, - "searchText": { - "description": "Search text to find in work items", - "type": "string" - }, - "skip": { - "default": 0, - "description": "Number of results to skip for pagination", - "type": "number" - }, - "state": { - "description": "Filter by work item states", - "items": { - "type": "string" - }, - "type": "array" - }, - "top": { - "default": 10, - "description": "Number of results to return", - "type": "number" - }, - "workItemType": { - "description": "Filter by work item types", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "searchText" - ], - "type": "object" - } - }, - { - "name": "advsec_get_alerts", - "description": "Retrieve Advanced Security alerts for a repository.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "alertType": { - "description": "Filter alerts by type. If not specified, returns all alert types.", - "enum": [ - "Unknown", - "Dependency", - "Secret", - "Code", - "License" - ], - "type": "string" - }, - "confidenceLevels": { - "default": [ - "high", - "other" - ], - "description": "Filter alerts by confidence levels. Only applicable for secret alerts. Defaults to both 'high' and 'other'.", - "items": { - "enum": [ - "High", - "Other" - ], - "type": "string" - }, - "type": "array" - }, - "continuationToken": { - "description": "Continuation token for pagination.", - "type": "string" - }, - "onlyDefaultBranch": { - "default": true, - "description": "If true, only return alerts found on the default branch. Defaults to true.", - "type": "boolean" - }, - "orderBy": { - "default": "severity", - "description": "Order results by specified field. Defaults to 'severity'.", - "enum": [ - "id", - "firstSeen", - "lastSeen", - "fixedOn", - "severity" - ], - "type": "string" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "ref": { - "description": "Filter alerts by git reference (branch). If not provided and onlyDefaultBranch is true, only includes alerts from default branch.", - "type": "string" - }, - "repository": { - "description": "The name or ID of the repository to get alerts for.", - "type": "string" - }, - "ruleId": { - "description": "Filter alerts by rule ID.", - "type": "string" - }, - "ruleName": { - "description": "Filter alerts by rule name.", - "type": "string" - }, - "severities": { - "description": "Filter alerts by severity level. If not specified, returns alerts at any severity.", - "items": { - "enum": [ - "Low", - "Medium", - "High", - "Critical", - "Note", - "Warning", - "Error", - "Undefined" - ], - "type": "string" - }, - "type": "array" - }, - "states": { - "description": "Filter alerts by state. If not specified, returns alerts in any state.", - "items": { - "enum": [ - "Unknown", - "Active", - "Dismissed", - "Fixed", - "AutoDismissed" - ], - "type": "string" - }, - "type": "array" - }, - "toolName": { - "description": "Filter alerts by tool name.", - "type": "string" - }, - "top": { - "default": 100, - "description": "Maximum number of alerts to return. Defaults to 100.", - "type": "number" - }, - "validity": { - "description": "Filter alerts by validity status. Only applicable for secret alerts.", - "items": { - "enum": [ - "None", - "Unknown", - "Active", - "Inactive" - ], - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "project", - "repository", - "confidenceLevels" - ], - "type": "object" - } - }, - { - "name": "advsec_get_alert_details", - "description": "Get detailed information about a specific Advanced Security alert.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "alertId": { - "description": "The ID of the alert to retrieve details for.", - "type": "number" - }, - "project": { - "description": "The name or ID of the Azure DevOps project.", - "type": "string" - }, - "ref": { - "description": "Git reference (branch) to filter the alert.", - "type": "string" - }, - "repository": { - "description": "The name or ID of the repository containing the alert.", - "type": "string" - } - }, - "required": [ - "project", - "repository", - "alertId" - ], - "type": "object" - } - } - ], - "refreshed_at": "2026-01-29T15:08:50.565179300+00:00" - }, - "msft-learn": { - "name": "msft-learn", - "builtin": true, - "tools": [ - { - "name": "microsoft_docs_search", - "description": "Search official Microsoft/Azure documentation to find the most relevant and trustworthy content for a user's query. This tool returns up to 10 high-quality content chunks (each max 500 tokens), extracted from Microsoft Learn and other official sources. Each result includes the article title, URL, and a self-contained content excerpt optimized for fast retrieval and reasoning. Always use this tool to quickly ground your answers in accurate, first-party Microsoft/Azure knowledge.\n\n## Follow-up Pattern\nTo ensure completeness, use microsoft_docs_fetch when high-value pages are identified by search. The fetch tool complements search by providing the full detail. This is a required step for comprehensive results.", - "input_schema": { - "properties": { - "query": { - "default": null, - "description": "a query or topic about Microsoft/Azure products, services, platforms, developer tools, frameworks, or APIs", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "microsoft_code_sample_search", - "description": "Search for code snippets and examples in official Microsoft Learn documentation. This tool retrieves relevant code samples from Microsoft documentation pages providing developers with practical implementation examples and best practices for Microsoft/Azure products and services related coding tasks. This tool will help you use the **LATEST OFFICIAL** code snippets to empower coding capabilities.\n\n## When to Use This Tool\n- When you are going to provide sample Microsoft/Azure related code snippets in your answers.\n- When you are **generating any Microsoft/Azure related code**.\n\n## Usage Pattern\nInput a descriptive query, or SDK/class/method name to retrieve related code samples. The optional parameter `language` can help to filter results.\n\nEligible values for `language` parameter include: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php", - "input_schema": { - "properties": { - "language": { - "default": null, - "description": "Optional parameter specifying the programming language of code snippets to retrieve. Can significantly improve search quality if provided. Eligible values: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php", - "type": "string" - }, - "query": { - "description": "a descriptive query, SDK name, method name or code snippet related to Microsoft/Azure products, services, platforms, developer tools, frameworks, APIs or SDKs", - "type": "string" - } - }, - "required": [ - "query" - ], - "type": "object" - } - }, - { - "name": "microsoft_docs_fetch", - "description": "Fetch and convert a Microsoft Learn documentation page to markdown format. This tool retrieves the latest complete content of Microsoft documentation pages including Azure, .NET, Microsoft 365, and other Microsoft technologies.\n\n## When to Use This Tool\n- When search results provide incomplete information or truncated content\n- When you need complete step-by-step procedures or tutorials\n- When you need troubleshooting sections, prerequisites, or detailed explanations\n- When search results reference a specific page that seems highly relevant\n- For comprehensive guides that require full context\n\n## Usage Pattern\nUse this tool AFTER microsoft_docs_search when you identify specific high-value pages that need complete content. The search tool gives you an overview; this tool gives you the complete picture.\n\n## URL Requirements\n- The URL must be a valid link from the microsoft.com domain.\n\n## Output Format\nmarkdown with headings, code blocks, tables, and links preserved.", - "input_schema": { - "properties": { - "url": { - "description": "URL of the Microsoft documentation page to read", - "type": "string" - } - }, - "required": [ - "url" - ], - "type": "object" - } - } - ], - "refreshed_at": "2026-01-29T15:09:30.073716200+00:00" - }, - "bluebird": { - "name": "bluebird", - "builtin": true, - "tools": [ - { - "name": "engineering_copilot_dynamic_tool_invoker", - "description": "Dynamically invokes a tool from engineering_copilot using the tool name and input.The tool input should only comes from the parameter section of the tool from the engineer copilot instructions.", - "input_schema": { - "properties": { - "toolInput": { - "description": "The input to pass to the tool", - "type": "string" - }, - "toolName": { - "description": "The name of the engineering_copilot tool to invoke", - "type": "string" - } - }, - "required": [ - "toolName", - "toolInput" - ], - "type": "object" - } - }, - { - "name": "engineering_copilot", - "description": "Retrieves system instructions and session context for the Engineering Copilot MCP server. This tool initializes the session with tailored guidance for handling development tasks.\r\n\r\nWhen to use this tool:\r\n- Call this tool at the start of a new conversation before performing other actions such as searching the workspace, reading files, or analyzing code.\r\n- This tool provides the foundational context that informs how subsequent tools should be selected and used.\r\n- The returned instructions contain information about available capabilities, recommended workflows, and task-specific strategies.\r\n\r\nWhy call this tool first:\r\n- It ensures the agent operates with the correct rules and context for the current session.\r\n- It provides updated system prompts that may affect how other tools behave or should be invoked.\r\n- Skipping this step may result in suboptimal tool selection or missing important session-specific guidance.\r\n\r\nParameters:\r\n- original_user_question: The user's question or prompt, passed exactly as provided.\r\n- conversationHistory: The conversation history for context-aware responses.\r\n\r\nReturns specialized instructions tailored to the user's request and session context.", - "input_schema": { - "properties": { - "conversationHistory": { - "description": "Conversation history", - "type": "string" - }, - "original_user_question": { - "description": "The original user question or prompt. Copy paste it AS IS.", - "type": "string" - } - }, - "required": [ - "original_user_question", - "conversationHistory" - ], - "type": "object" - } - } - ], - "refreshed_at": "2026-01-29T15:08:55.397366500+00:00" - }, - "ado-ext": { - "name": "ado-ext", - "builtin": true, - "tools": [ - { - "name": "get_component_governance_instructions", - "description": "Get specific instructions on how to fix a component governance alert. Use this tool to get more specific instructions on how to fix a component governance security alert.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Request for Azure DevOps Component Governance Instructions", - "properties": { - "alert_id": { - "description": "Alert Id", - "format": "int32", - "type": "integer" - }, - "branch": { - "description": "Branch name (e.g., 'main', 'develop')", - "type": "string" - }, - "organization": { - "description": "Organization Name", - "type": "string" - }, - "project": { - "description": "Project Name or GUID", - "type": "string" - }, - "repository": { - "description": "Repository Id or Name", - "type": "string" - } - }, - "required": [ - "organization", - "project", - "repository", - "alert_id", - "branch" - ], - "title": "ComponentGovernanceInstructionsRequest", - "type": "object" - } - }, - { - "name": "get_component_governance_alert", - "description": "Get the component governance alert for the alert id. Use this tool when you need to get additional info for security vulnerabilities, license compliance issues, or other component governance alerts whenever an html link to azure dev ops containing componentGovernance substring in the user prompt.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Request for Azure DevOps Component Governance Alert", - "properties": { - "alert_id": { - "description": "Alert Id", - "format": "int32", - "type": "integer" - }, - "organization": { - "description": "Organization Name", - "type": "string" - }, - "project": { - "description": "Project Name or GUID", - "type": "string" - }, - "repository": { - "description": "Repository Id or Name", - "type": "string" - } - }, - "required": [ - "organization", - "project", - "repository", - "alert_id" - ], - "title": "ComponentGovernanceAlertRequest", - "type": "object" - } - }, - { - "name": "get_component_governance_alerts", - "description": "Get multiple component governance alerts for a repository. Use this tool to retrieve all alerts or filter by snapshot type or alert state. Alert state enum values: Unknown=0, Active=1, Dismissed=2, Fixed=4, AutoDismissed=8. Returns a list of security vulnerabilities, license compliance issues, and other component governance alerts.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Request for Azure DevOps Component Governance Alerts (multiple)", - "properties": { - "alert_state": { - "description": "Alert State (optional) - Enum integer values: Unknown=0, Active=1, Dismissed=2, Fixed=4, AutoDismissed=8", - "format": "int32", - "nullable": true, - "type": "integer" - }, - "organization": { - "description": "Organization Name", - "type": "string" - }, - "project": { - "description": "Project Name or GUID", - "type": "string" - }, - "repository": { - "description": "Repository Id or Name", - "type": "string" - }, - "snapshot_type_id": { - "description": "Snapshot Type Id (optional) - Filter by snapshot type", - "format": "int32", - "nullable": true, - "type": "integer" - } - }, - "required": [ - "organization", - "project", - "repository" - ], - "title": "ComponentGovernanceAlertsRequest", - "type": "object" - } - }, - { - "name": "get_component_governance_alert_from_link", - "description": "Get component governance alert from a component governance link. Example link: https://dev.azure.com/{organization}/{project}/_componentGovernance/{componentGovernanceId}?alertId={alertId}", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Request for Azure DevOps Component Governance Alert from Link", - "properties": { - "alert_id": { - "description": "Alert Id", - "format": "int32", - "type": "integer" - }, - "organization": { - "description": "Organization Name", - "type": "string" - }, - "project": { - "description": "Project Name or GUID", - "type": "string" - }, - "repository": { - "description": "Repository Id or Name", - "type": "string" - }, - "snapshot_type_id": { - "description": "Snapshot Type Id (optional)", - "nullable": true, - "type": "string" - } - }, - "required": [ - "organization", - "project", - "repository", - "alert_id" - ], - "title": "ComponentGovernanceAlertFromLinkRequest", - "type": "object" - } - } - ], - "refreshed_at": "2026-01-29T15:08:50.737177200+00:00" - }, - "kusto": { - "name": "kusto", - "builtin": true, - "tools": [], - "refreshed_at": "2026-01-29T15:08:50.779286300+00:00", - "error": "Invalid JSON response: " - }, - "icm": { - "name": "icm", - "builtin": true, - "tools": [], - "refreshed_at": "2026-01-29T15:09:25.402397200+00:00", - "error": "Timeout after 30s" - }, - "stack": { - "name": "stack", - "builtin": true, - "tools": [ - { - "name": "push_multiple", - "description": "Push multiple strings onto the stack in the provided order", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "values": { - "description": "Array of string values to push onto the stack", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "values" - ], - "title": "PushMultipleRequest", - "type": "object" - } - }, - { - "name": "peek", - "description": "Peek at the top value without removing it; returns value and remaining count", - "input_schema": { - "properties": {}, - "type": "object" - } - }, - { - "name": "pop_all", - "description": "Pop all values from the stack and return them in pop order (top first)", - "input_schema": { - "properties": {}, - "type": "object" - } - }, - { - "name": "clear", - "description": "Clear the stack without returning any values", - "input_schema": { - "properties": {}, - "type": "object" - } - }, - { - "name": "push", - "description": "Push a single string onto the stack", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "value": { - "description": "The string value to push onto the stack", - "type": "string" - } - }, - "required": [ - "value" - ], - "title": "PushRequest", - "type": "object" - } - }, - { - "name": "pop", - "description": "Pop the top value from the stack; returns value and remaining count", - "input_schema": { - "properties": {}, - "type": "object" - } - } - ], - "refreshed_at": "2026-01-29T15:08:44.417568900+00:00" - }, - "calculator": { - "name": "calculator", - "builtin": true, - "tools": [ - { - "name": "sub", - "description": "Calculate the difference of two numbers", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "a": { - "description": "the left hand side number", - "format": "int32", - "type": "integer" - }, - "b": { - "description": "the right hand side number", - "format": "int32", - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], - "title": "SubRequest", - "type": "object" - } - }, - { - "name": "sum", - "description": "Calculate the sum of two numbers", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "a": { - "description": "the left hand side number", - "format": "int32", - "type": "integer" - }, - "b": { - "description": "the right hand side number", - "format": "int32", - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], - "title": "SumRequest", - "type": "object" - } - } - ], - "refreshed_at": "2026-01-29T15:08:44.355387200+00:00" - }, - "es-chat": { - "name": "es-chat", - "builtin": true, - "tools": [ - { - "name": "es_resolve", - "description": "Attempts to resolve the identifier of an entity in engineering systems (aka engineering, engsys, es etc.) to information about the entity.", - "input_schema": { - "properties": { - "identifier": { - "description": "An entity identifier (preferably a URL or GUID, sometimes just a numeric ID) that is present in the conversation", - "type": "string" - }, - "question": { - "description": "A self-sufficient version of the question that prompted the call to this resolve tool. DO NOT qualify with 'engineering systems', 'engineering', 'engsys', 'es' etc. as the tool is already scoped to engineering and this will mislead it.", - "type": "string" - } - }, - "required": [ - "identifier", - "question" - ], - "type": "object" - } - }, - { - "name": "es_ask", - "description": "Asks ES Chat a question about engineering systems (aka engineering, engsys, es etc.) ES Chat is an AI-powered support assistant for Microsoft engineering systems that can provide information about engineering systems, assist with onboarding procedures, and help diagnose concerns regarding engineering assets. It searches internal knowledgebases such as the Engineering Hub (aka Eng Hub), ADO wikis and work items, IcM incidents and custom sources, and has a wealth of custom tools that can perform specific engineering tasks.", - "input_schema": { - "properties": { - "question": { - "description": "A self-sufficient version of the question that prompted the call to this search tool. DO NOT qualify with 'engineering systems', 'engineering', 'engsys', 'es' etc. as the tool is already scoped to engineering and this will mislead it.", - "type": "string" - } - }, - "required": [ - "question" - ], - "type": "object" - } - }, - { - "name": "es_search", - "description": "Searches for information across various engineering systems (aka engineering, engsys, es etc.) to address the current question.", - "input_schema": { - "properties": { - "keywords": { - "description": "Keywords representing concepts or problems underlying the current question. DO NOT qualify with 'engineering systems', 'engineering', 'engsys', 'es' etc. as the tool is already scoped to engineering and this will mislead it.", - "type": "string" - }, - "question": { - "description": "A self-sufficient version of the question that prompted the call to this search tool. DO NOT qualify with 'engineering systems', 'engineering', 'engsys', 'es' etc. as the tool is already scoped to engineering and this will mislead it.", - "type": "string" - } - }, - "required": [ - "keywords", - "question" - ], - "type": "object" - } - } - ], - "refreshed_at": "2026-01-29T15:09:29.426006+00:00" - }, - "asa": { - "name": "asa", - "builtin": true, - "tools": [ - { - "name": "report_progress", - "description": "Reports progress on the current task. Commits changes and generates a PR description.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "commit_message": { - "description": "Short single-line commit message", - "type": "string" - }, - "pr_description": { - "description": "Markdown checklist of completed/pending work", - "type": "string" - } - }, - "required": [ - "commit_message", - "pr_description" - ], - "type": "object" - } - }, - { - "name": "build_and_validate", - "description": "Builds and validates the codebase. Use after making code changes.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "project_type": { - "description": "Override auto-detected type: rust, typescript, dotnet", - "type": "string" - }, - "run_validation": { - "description": "Run linting/formatting checks (default: true)", - "type": "boolean" - }, - "target": { - "description": "Specific target/project to build", - "type": "string" - }, - "working_directory": { - "description": "Working directory (default: git root)", - "type": "string" - } - }, - "required": [], - "type": "object" - } - }, - { - "name": "reply_to_comment", - "description": "Replies to a comment thread on the current PR.", - "input_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "comment_thread_id": { - "description": "ID from in a block", - "type": "string" - }, - "content": { - "description": "Reply message content", - "type": "string" - } - }, - "required": [ - "comment_thread_id", - "content" - ], - "type": "object" - } - } - ], - "refreshed_at": "2026-02-05T00:00:00.000000+00:00" - } - } -} \ No newline at end of file diff --git a/src/compile/common.rs b/src/compile/common.rs index 8f31404..1b2184d 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -4,12 +4,12 @@ use anyhow::{Context, Result}; use super::types::{FrontMatter, McpConfig, Repository, TriggerConfig}; use crate::fuzzy_schedule; -use crate::mcp_metadata::McpMetadataFile; -/// Check if an MCP name is a built-in (launched via agency mcp) -pub fn is_builtin_mcp(name: &str) -> bool { - let metadata = McpMetadataFile::bundled(); - metadata.get(name).map(|m| m.builtin).unwrap_or(false) +/// Check if an MCP has a custom command (i.e., is not just a name-based reference). +/// All MCPs now require explicit command configuration — there are no built-in MCPs +/// in the copilot CLI. +pub fn is_custom_mcp(config: &McpConfig) -> bool { + matches!(config, McpConfig::WithOptions(opts) if opts.command.is_some()) } /// Parse the markdown file and extract front matter and body @@ -306,14 +306,9 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String { allowed_tools.push(format!("shell({})", cmd)); } - let metadata = McpMetadataFile::bundled(); - let mut disallowed_mcps: Vec<&str> = metadata.mcp_names(); - disallowed_mcps.sort(); - let mut params = Vec::new(); params.push(format!("--model {}", front_matter.engine.model())); - params.push("--disable-builtin-mcps".to_string()); params.push("--no-ask-user".to_string()); for tool in allowed_tools { @@ -326,26 +321,6 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String { } } - for mcp in disallowed_mcps { - params.push(format!("--disable-mcp-server {}", mcp)); - } - - for (name, config) in &front_matter.mcp_servers { - let is_custom = matches!(config, McpConfig::WithOptions(opts) if opts.command.is_some()); - if is_custom { - continue; - } - - let is_enabled = match config { - McpConfig::Enabled(enabled) => *enabled, - McpConfig::WithOptions(_) => true, - }; - - if is_enabled { - params.push(format!("--mcp {}", name)); - } - } - params.join(" ") } @@ -460,6 +435,12 @@ pub const DEFAULT_POOL: &str = "AZS-1ES-L-MMS-ubuntu-22.04"; /// See: https://github.com/github/gh-aw-firewall/releases pub const AWF_VERSION: &str = "0.23.1"; +/// Docker image and version for the MCP Gateway (gh-aw-mcpg). +/// Update this when upgrading to a new MCPG release. +/// See: https://github.com/github/gh-aw-mcpg/releases +pub const MCPG_VERSION: &str = "0.1.9"; +pub const MCPG_IMAGE: &str = "ghcr.io/github/gh-aw-mcpg"; + /// Generate source path for the execute command. /// /// Returns a path using `{{ workspace }}` as the base, which gets resolved @@ -604,7 +585,7 @@ mod tests { } #[test] - fn test_copilot_params_custom_mcp_not_added_with_mcp_flag() { + fn test_copilot_params_custom_mcp_not_in_params() { let mut fm = minimal_front_matter(); fm.mcp_servers.insert( "my-tool".to_string(), @@ -614,17 +595,20 @@ mod tests { }), ); let params = generate_copilot_params(&fm); - // Custom MCPs (with command) should NOT appear as --mcp flags - assert!(!params.contains("--mcp my-tool")); + // MCPs are handled by MCPG, not copilot CLI params + assert!(!params.contains("my-tool")); } #[test] - fn test_copilot_params_builtin_mcp_added_with_mcp_flag() { + fn test_copilot_params_no_mcp_flags() { let mut fm = minimal_front_matter(); fm.mcp_servers .insert("ado".to_string(), McpConfig::Enabled(true)); let params = generate_copilot_params(&fm); - assert!(params.contains("--mcp ado")); + // No --mcp or --disable-mcp-server flags — MCPs are handled by MCPG + assert!(!params.contains("--mcp")); + assert!(!params.contains("--disable-mcp-server")); + assert!(!params.contains("--disable-builtin-mcps")); } // ─── sanitize_filename ──────────────────────────────────────────────────── diff --git a/src/compile/onees.rs b/src/compile/onees.rs index b3f754c..83c8c87 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -21,7 +21,7 @@ use super::common::{ generate_checkout_self, generate_checkout_steps, generate_ci_trigger, generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path, - generate_working_directory, replace_with_indent, + generate_working_directory, is_custom_mcp, replace_with_indent, }; use super::types::{FrontMatter, McpConfig}; @@ -177,17 +177,29 @@ fn generate_agent_context_root(effective_workspace: &str) -> String { } } -/// Generate MCP configuration for 1ES templates +/// Generate MCP configuration for 1ES templates. +/// +/// In 1ES, MCPs require service connections. Only MCPs with explicit +/// `service_connection` configuration or custom commands are included. fn generate_mcp_configuration(mcps: &HashMap) -> String { let mut mcp_entries: Vec<_> = mcps .iter() .filter_map(|(name, config)| { let (is_enabled, opts) = match config { McpConfig::Enabled(enabled) => (*enabled, None), - McpConfig::WithOptions(o) => (o.command.is_none(), Some(o)), // Custom MCPs not supported + McpConfig::WithOptions(o) => (true, Some(o)), }; - if !is_enabled || !common::is_builtin_mcp(name) { + if !is_enabled { + return None; + } + + // Custom MCPs with command: not supported in 1ES (needs service connection) + if is_custom_mcp(config) { + log::warn!( + "MCP '{}' uses custom command — not supported in 1ES target (requires service connection)", + name + ); return None; } diff --git a/src/create.rs b/src/create.rs index 3cdfd75..67e6aa8 100644 --- a/src/create.rs +++ b/src/create.rs @@ -5,7 +5,6 @@ use std::fmt; use std::path::PathBuf; use crate::compile::sanitize_filename; -use crate::mcp_metadata::McpMetadataFile; /// Available AI models for agent configuration const AVAILABLE_MODELS: &[&str] = &[ @@ -132,7 +131,6 @@ pub async fn create_agent(output_dir: Option) -> Result<()> { let mut config = AgentConfig::default(); let mut step = WizardStep::Name; - let mcp_metadata = McpMetadataFile::bundled(); loop { match step { @@ -265,7 +263,7 @@ pub async fn create_agent(output_dir: Option) -> Result<()> { } WizardStep::Mcps => { - match prompt_mcps_with_back(&mcp_metadata, &mut step)? { + match prompt_mcps_with_back(&mut step)? { Some(mcps) => { config.mcps = mcps; step = step.next(); @@ -566,110 +564,64 @@ fn prompt_schedule_with_back(step: &mut WizardStep) -> Result Result>> { - use std::collections::{HashMap, HashSet}; - use terminal_size::{Height, Width, terminal_size}; - - // Get terminal dimensions for dynamic sizing - let (term_width, term_height) = terminal_size() - .map(|(Width(w), Height(h))| (w as usize, h as usize)) - .unwrap_or((80, 24)); - - let page_size = term_height.saturating_sub(10).max(5).min(30); - - let builtin_mcps = metadata.builtin_mcp_names(); - - let mut all_tools: Vec = Vec::new(); - for mcp_name in &builtin_mcps { - if let Some(mcp) = metadata.get(mcp_name) { - for tool in &mcp.tools { - all_tools.push(McpToolOption { - mcp_name: mcp_name.to_string(), - tool_name: tool.name.clone(), - description: tool.description.clone().unwrap_or_default(), - max_width: term_width, - }); - } +/// Prompt for MCPs with back navigation. +/// +/// There are no built-in MCPs — all MCPs require explicit command configuration. +/// The wizard collects custom MCP names; command/args are configured in the +/// generated markdown front matter. +fn prompt_mcps_with_back(step: &mut WizardStep) -> Result>> { + println!("\n🔧 MCP Server Configuration"); + println!("Add custom MCP servers. Each requires a command and args in the front matter."); + println!("You can add MCP servers later by editing the generated markdown file.\n"); + + let add_mcps = match Confirm::new("Would you like to add any custom MCP servers?") + .with_default(false) + .prompt() + { + Ok(val) => val, + Err(InquireError::OperationCanceled) => { + *step = step.prev(); + return Ok(None); } - } - - all_tools.sort_by(|a, b| a.full_name().cmp(&b.full_name())); - - let total_tools = all_tools.len(); - let total_mcps = builtin_mcps.len(); - - println!("\n🔧 MCP Tool Selection"); - println!("Select tools to enable. Tools are shown as mcp:tool_name."); - println!("Type to search/filter, Space to toggle, Enter to confirm, Esc to go back."); - println!("({} tools across {} MCPs)\n", total_tools, total_mcps); - - let prompt = MultiSelect::new("Select tools to enable:", all_tools) - .with_help_message( - "Type to filter (e.g., 'ado:' or 'work_item'), Space to toggle, Enter to confirm", - ) - .with_page_size(page_size) - .prompt(); + Err(InquireError::OperationInterrupted) => { + anyhow::bail!("Wizard interrupted"); + } + Err(e) => return Err(e).context("Failed to prompt for MCPs"), + }; - match prompt { - Ok(selected) => { - if selected.is_empty() { - return Ok(Some(Vec::new())); - } + if !add_mcps { + return Ok(Some(Vec::new())); + } - let mut mcp_tools: HashMap> = HashMap::new(); - for tool in selected { - mcp_tools - .entry(tool.mcp_name.clone()) - .or_default() - .insert(tool.tool_name); + let mut selections = Vec::new(); + loop { + let name = match Text::new("MCP server name (or empty to finish):") + .with_help_message("e.g., my-custom-tool") + .prompt() + { + Ok(name) if name.trim().is_empty() => break, + Ok(name) => name.trim().to_string(), + Err(InquireError::OperationCanceled) => break, + Err(InquireError::OperationInterrupted) => { + anyhow::bail!("Wizard interrupted"); } + Err(e) => return Err(e).context("Failed to read MCP name"), + }; - let mut mcp_selections: Vec = mcp_tools - .into_iter() - .map(|(mcp_name, selected_tools)| { - let total_for_mcp = metadata.get(&mcp_name).map(|m| m.tools.len()).unwrap_or(0); - - if selected_tools.len() == total_for_mcp { - McpSelection { - name: mcp_name, - allowed_tools: None, - } - } else { - let mut tools: Vec = selected_tools.into_iter().collect(); - tools.sort(); - McpSelection { - name: mcp_name, - allowed_tools: Some(tools), - } - } - }) - .collect(); - - mcp_selections.sort_by(|a, b| a.name.cmp(&b.name)); - - println!("\n📋 Selected {} MCPs:", mcp_selections.len()); - for mcp in &mcp_selections { - match &mcp.allowed_tools { - None => println!(" {} (all tools)", mcp.name), - Some(tools) => println!(" {} ({} tools)", mcp.name, tools.len()), - } - } + selections.push(McpSelection { + name, + allowed_tools: None, + }); + } - Ok(Some(mcp_selections)) - } - Err(InquireError::OperationCanceled) => { - *step = step.prev(); - Ok(None) + if !selections.is_empty() { + println!("\n📋 Added {} custom MCP(s):", selections.len()); + for mcp in &selections { + println!(" {} (configure command/args in front matter)", mcp.name); } - Err(InquireError::OperationInterrupted) => { - anyhow::bail!("Wizard interrupted"); - } - Err(e) => Err(e).context("Failed to select tools"), } + + Ok(Some(selections)) } /// Workspace option for display @@ -905,49 +857,6 @@ fn prompt_custom_schedule() -> Result> { } } -/// MCP tool option for flat list display (mcp:tool format) -struct McpToolOption { - mcp_name: String, - tool_name: String, - description: String, - /// Maximum width for the display (set based on terminal width) - max_width: usize, -} - -impl McpToolOption { - fn full_name(&self) -> String { - format!("{}:{}", self.mcp_name, self.tool_name) - } -} - -impl fmt::Display for McpToolOption { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let full_name = self.full_name(); - if self.description.is_empty() { - write!(f, "{}", full_name) - } else { - // Calculate available space for description - // Format is: "mcp:tool_name: description" - // Account for ": " separator (2 chars) and margin for inquire's UI - let prefix_len = full_name.len() + 2; - let margin = 6; // Space for checkbox, cursor, and padding - let available = self.max_width.saturating_sub(prefix_len + margin); - - if available < 10 { - // Not enough space for description, just show the name - write!(f, "{}", full_name) - } else { - let desc = if self.description.len() > available { - format!("{}...", &self.description[..available.saturating_sub(3)]) - } else { - self.description.clone() - }; - write!(f, "{}: {}", full_name, desc) - } - } - } -} - /// Generate the markdown file content from the configuration fn generate_markdown(config: &AgentConfig) -> String { let mut yaml_parts = Vec::new(); diff --git a/src/main.rs b/src/main.rs index 3c39a58..b1442e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,8 +5,6 @@ mod execute; mod fuzzy_schedule; mod logging; mod mcp; -mod mcp_firewall; -mod mcp_metadata; mod ndjson; mod proxy; pub mod sanitize; @@ -73,11 +71,18 @@ enum Commands { #[arg(long = "allow")] allowed_hosts: Vec, }, - /// Start an MCP firewall server that proxies and filters tool calls to upstream MCPs - McpFirewall { - /// Path to the firewall configuration JSON file - #[arg(short, long)] - config: PathBuf, + /// Run SafeOutputs MCP server over HTTP (for MCPG integration) + McpHttp { + /// Port to listen on + #[arg(long, default_value = "8100")] + port: u16, + /// API key for authentication (if not provided, one is generated) + #[arg(long)] + api_key: Option, + /// Directory for safe output files + output_directory: String, + /// Guard against directory traversal attacks + bounding_directory: String, }, } @@ -106,7 +111,7 @@ async fn main() -> Result<()> { Some(Commands::Mcp { .. }) => "mcp", Some(Commands::Execute { .. }) => "execute", Some(Commands::Proxy { .. }) => "proxy", - Some(Commands::McpFirewall { .. }) => "mcp-firewall", + Some(Commands::McpHttp { .. }) => "mcp-http", None => "ado-aw", }; @@ -228,8 +233,14 @@ async fn main() -> Result<()> { #[cfg(windows)] std::future::pending::<()>().await; } - Commands::McpFirewall { config } => { - mcp_firewall::run(&config).await?; + Commands::McpHttp { + port, + api_key, + output_directory, + bounding_directory, + } => { + mcp::run_http(&output_directory, &bounding_directory, port, api_key.as_deref()) + .await?; } } } else { diff --git a/src/mcp_firewall.rs b/src/mcp_firewall.rs deleted file mode 100644 index dee4327..0000000 --- a/src/mcp_firewall.rs +++ /dev/null @@ -1,776 +0,0 @@ -//! MCP Firewall - A filtering proxy for Model Context Protocol servers -//! -//! The firewall acts as a single MCP server that: -//! 1. Loads tool definitions from pre-generated metadata (mcp-metadata.json) -//! 2. Exposes only allowed tools (namespaced as `upstream:tool_name`) -//! 3. Spawns upstream MCP servers lazily when tools are called -//! 4. Routes tool calls to the appropriate upstream -//! 5. Logs all tool call attempts for auditing - -use anyhow::{Context, Result}; -use log::{debug, error, info, warn}; -use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, model::*, - service::RequestContext, transport::stdio, -}; -use serde::{Deserialize, Serialize}; -use std::borrow::Cow; -use std::collections::HashMap; -use std::path::PathBuf; -use std::process::Stdio; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, Command}; -use tokio::sync::RwLock; - -use crate::mcp_metadata::{McpMetadataFile, ToolMetadata}; - -// ============================================================================ -// Configuration -// ============================================================================ - -/// Configuration for a single upstream MCP server -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UpstreamConfig { - /// Command to spawn the MCP server - pub command: String, - /// Arguments to pass to the command - #[serde(default)] - pub args: Vec, - /// Environment variables for the MCP server process - #[serde(default)] - pub env: HashMap, - /// List of allowed tool names (without namespace prefix) - /// Use ["*"] to allow all tools - pub allowed: Vec, - /// Timeout in seconds for spawning and initializing the upstream MCP server - /// Defaults to 30 seconds if not specified - #[serde(default = "default_spawn_timeout")] - pub spawn_timeout_secs: u64, -} - -fn default_spawn_timeout() -> u64 { - 30 -} - -impl UpstreamConfig { - /// Check if a tool name is allowed by this upstream's policy - pub fn is_tool_allowed(&self, tool_name: &str) -> bool { - self.allowed.iter().any(|pattern| { - if pattern == "*" { - true - } else if pattern.ends_with('*') { - // Prefix wildcard: "get_*" matches "get_incident", "get_user" - let prefix = &pattern[..pattern.len() - 1]; - tool_name.starts_with(prefix) - } else { - pattern == tool_name - } - }) - } -} - -/// Full firewall configuration -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct FirewallConfig { - /// Map of upstream name to configuration - pub upstreams: HashMap, - /// Path to MCP metadata file (optional, uses bundled metadata if not provided) - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata_path: Option, -} - -impl FirewallConfig { - /// Load configuration from a JSON file - pub fn from_file(path: &PathBuf) -> Result { - let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file: {}", path.display()))?; - serde_json::from_str(&content) - .with_context(|| format!("Failed to parse config file: {}", path.display())) - } - - /// Load MCP metadata (from file if specified, otherwise bundled) - pub fn load_metadata(&self) -> Result { - if let Some(ref path) = self.metadata_path { - let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read metadata file: {}", path.display()))?; - serde_json::from_str(&content) - .with_context(|| format!("Failed to parse metadata file: {}", path.display())) - } else { - Ok(McpMetadataFile::bundled()) - } - } - - /// Create a new empty configuration - pub fn new() -> Self { - Self { - upstreams: HashMap::new(), - metadata_path: None, - } - } -} - -impl Default for FirewallConfig { - fn default() -> Self { - Self::new() - } -} - -// ============================================================================ -// Upstream MCP Client -// ============================================================================ - -/// A connection to an upstream MCP server (spawned lazily) -struct UpstreamConnection { - name: String, - #[allow(dead_code)] - child: Child, - stdin: tokio::process::ChildStdin, - stdout_reader: BufReader, - request_id: u64, -} - -impl UpstreamConnection { - /// Spawn and initialize an upstream MCP server with timeout - async fn spawn(name: String, config: &UpstreamConfig) -> Result { - let timeout_duration = std::time::Duration::from_secs(config.spawn_timeout_secs); - let start_time = std::time::Instant::now(); - - info!( - "[{}] Spawning upstream MCP server (timeout: {}s)", - name, config.spawn_timeout_secs - ); - - // Wrap the entire spawn+initialize sequence in a timeout - let result = tokio::time::timeout(timeout_duration, async { - let mut cmd = Command::new(&config.command); - cmd.args(&config.args); - - for (key, value) in &config.env { - cmd.env(key, value); - } - - cmd.stdin(Stdio::piped()); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::inherit()); // Let upstream errors flow to our stderr - - let mut child = cmd.spawn().with_context(|| { - format!("Failed to spawn upstream '{}': {}", name, config.command) - })?; - - let stdin = child.stdin.take().ok_or_else(|| { - anyhow::anyhow!("Failed to capture stdin for upstream '{}'", name) - })?; - let stdout = child.stdout.take().ok_or_else(|| { - anyhow::anyhow!("Failed to capture stdout for upstream '{}'", name) - })?; - - let mut conn = Self { - name: name.clone(), - child, - stdin, - stdout_reader: BufReader::new(stdout), - request_id: 0, - }; - - // Initialize the MCP connection - conn.initialize().await?; - - Ok::(conn) - }) - .await; - - let duration = start_time.elapsed(); - - match result { - Ok(Ok(conn)) => { - info!( - "[{}] Successfully spawned and initialized in {:.2}s", - name, - duration.as_secs_f64() - ); - Ok(conn) - } - Ok(Err(e)) => { - error!( - "[{}] Failed to spawn/initialize after {:.2}s: {}", - name, - duration.as_secs_f64(), - e - ); - Err(e) - } - Err(_) => { - error!( - "[{}] Spawn timeout after {:.2}s (limit: {}s)", - name, - duration.as_secs_f64(), - config.spawn_timeout_secs - ); - anyhow::bail!( - "Timeout spawning upstream '{}' after {}s. The MCP server may be hanging during initialization. \ - Check that the command '{}' is responsive and properly configured.", - name, - config.spawn_timeout_secs, - config.command - ) - } - } - } - - /// Send a JSON-RPC request and wait for response - async fn send_request( - &mut self, - method: &str, - params: Option, - ) -> Result { - self.request_id += 1; - let id = self.request_id; - - let request = if let Some(p) = params { - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": p - }) - } else { - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": method - }) - }; - - let request_str = serde_json::to_string(&request)?; - debug!("[{}] Sending: {}", self.name, request_str); - - self.stdin.write_all(request_str.as_bytes()).await?; - self.stdin.write_all(b"\n").await?; - self.stdin.flush().await?; - - // Read response - let mut line = String::new(); - self.stdout_reader - .read_line(&mut line) - .await - .with_context(|| format!("Failed to read response from upstream '{}'", self.name))?; - - debug!("[{}] Received: {}", self.name, line.trim()); - - let response: serde_json::Value = serde_json::from_str(&line).with_context(|| { - format!( - "Failed to parse response from upstream '{}': {}", - self.name, line - ) - })?; - - // Check for error - if let Some(error) = response.get("error") { - anyhow::bail!("Upstream '{}' returned error: {}", self.name, error); - } - - Ok(response - .get("result") - .cloned() - .unwrap_or(serde_json::Value::Null)) - } - - /// Initialize the MCP connection - async fn initialize(&mut self) -> Result<()> { - let params = serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": {} - }, - "clientInfo": { - "name": "mcp-firewall", - "version": env!("CARGO_PKG_VERSION") - } - }); - - let result = self.send_request("initialize", Some(params)).await?; - info!( - "[{}] Initialized: {:?}", - self.name, - result.get("serverInfo") - ); - - // Send initialized notification - let notification = serde_json::json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - }); - let notification_str = serde_json::to_string(¬ification)?; - self.stdin.write_all(notification_str.as_bytes()).await?; - self.stdin.write_all(b"\n").await?; - self.stdin.flush().await?; - - Ok(()) - } - - /// Call a tool on this upstream - async fn call_tool( - &mut self, - tool_name: &str, - arguments: Option>, - ) -> Result { - let params = serde_json::json!({ - "name": tool_name, - "arguments": arguments.unwrap_or_default() - }); - - let result = self.send_request("tools/call", Some(params)).await?; - - // Parse the result into CallToolResult - let content: Vec = - if let Some(content_array) = result.get("content").and_then(|c| c.as_array()) { - content_array - .iter() - .filter_map(|c| serde_json::from_value(c.clone()).ok()) - .collect() - } else { - vec![] - }; - - let is_error = result - .get("isError") - .and_then(|e| e.as_bool()) - .unwrap_or(false); - - let mut result = CallToolResult::success(content); - result.is_error = Some(is_error); - Ok(result) - } -} - -// ============================================================================ -// MCP Firewall Server -// ============================================================================ - -/// Policy for the MCP firewall -#[derive(Debug, Clone)] -pub struct FirewallPolicy { - pub config: FirewallConfig, -} - -/// The MCP Firewall server -pub struct McpFirewall { - /// Upstream configs (for lazy spawning) - upstream_configs: HashMap, - /// Lazily spawned upstream connections - upstreams: RwLock>, - /// Combined list of all allowed tools (namespaced) - tools: Vec, -} - -impl McpFirewall { - /// Create the firewall from policy and metadata - pub fn new(policy: FirewallPolicy) -> Result { - // Load metadata - let metadata = policy.config.load_metadata()?; - let mut all_tools = Vec::new(); - - // Build tool list from metadata (filtered by allowed list) - for (upstream_name, upstream_config) in &policy.config.upstreams { - if let Some(mcp_meta) = metadata.get(upstream_name) { - for tool_meta in &mcp_meta.tools { - // Check if this tool is allowed - if upstream_config.is_tool_allowed(&tool_meta.name) { - all_tools.push(Self::metadata_to_tool(upstream_name, tool_meta)); - } - } - info!( - "[{}] Loaded {} tools from metadata ({} allowed)", - upstream_name, - mcp_meta.tools.len(), - all_tools - .iter() - .filter(|t| t.name.starts_with(&format!("{}:", upstream_name))) - .count() - ); - } else { - warn!( - "[{}] No metadata found - tools will be unavailable", - upstream_name - ); - } - } - - info!( - "MCP Firewall initialized with {} upstreams, {} total tools (lazy spawning enabled)", - policy.config.upstreams.len(), - all_tools.len() - ); - - Ok(Self { - upstream_configs: policy.config.upstreams.clone(), - upstreams: RwLock::new(HashMap::new()), - tools: all_tools, - }) - } - - /// Convert ToolMetadata to rmcp Tool with namespace prefix - fn metadata_to_tool(upstream_name: &str, meta: &ToolMetadata) -> Tool { - Tool { - name: Cow::Owned(format!("{}:{}", upstream_name, meta.name)), - description: meta.description.clone().map(Cow::Owned), - input_schema: meta - .input_schema - .clone() - .and_then(|v| serde_json::from_value(v).ok()) - .unwrap_or_default(), - annotations: None, - icons: None, - output_schema: None, - title: None, - } - } - - /// Get or spawn an upstream connection - async fn get_or_spawn_upstream(&self, upstream_name: &str) -> Result<(), McpError> { - // Fast path: check if already spawned with read lock - { - let upstreams = self.upstreams.read().await; - if upstreams.contains_key(upstream_name) { - return Ok(()); - } - } - - // Need to spawn - get config first - let config = self.upstream_configs.get(upstream_name).ok_or_else(|| { - McpError::invalid_params(format!("Unknown upstream: '{}'", upstream_name), None) - })?; - - info!("[{}] Spawning upstream MCP server (lazy)", upstream_name); - - // Spawn the connection outside of any locks (this is the expensive operation) - let conn = UpstreamConnection::spawn(upstream_name.to_string(), config) - .await - .map_err(|e| { - McpError::internal_error(format!("Failed to spawn upstream: {}", e), None) - })?; - - // Acquire write lock and check again (double-check pattern) - // Another task might have spawned and inserted while we were spawning above - let mut upstreams = self.upstreams.write().await; - if !upstreams.contains_key(upstream_name) { - upstreams.insert(upstream_name.to_string(), conn); - } - // If another task already inserted, our `conn` is dropped here, which terminates - // the child process via Drop. This prevents duplicate upstream connections. - - Ok(()) - } - - /// Log a message via centralized logging - fn log(&self, message: &str) { - info!(target: "firewall", "{}", message); - } - - /// Parse a namespaced tool name into (upstream, tool_name) - fn parse_tool_name(namespaced: &str) -> Option<(&str, &str)> { - namespaced.split_once(':') - } - - /// Check if a tool is allowed by upstream config - fn is_tool_allowed(&self, upstream_name: &str, tool_name: &str) -> bool { - self.upstream_configs - .get(upstream_name) - .map(|c| c.is_tool_allowed(tool_name)) - .unwrap_or(false) - } -} - -impl ServerHandler for McpFirewall { - fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some( - "MCP Firewall - A secure proxy for accessing multiple MCP servers with policy-based filtering.".into() - ), - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..Default::default() - } - } - - async fn list_tools( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListToolsResult { - tools: self.tools.clone(), - next_cursor: None, - }) - } - - async fn call_tool( - &self, - request: CallToolRequestParam, - _context: RequestContext, - ) -> Result { - let tool_name = &request.name; - - // Parse namespaced tool name - let (upstream_name, local_tool_name) = match Self::parse_tool_name(tool_name) { - Some((u, t)) => (u, t), - None => { - self.log(&format!( - "BLOCKED {} (invalid format, expected 'upstream:tool')", - tool_name - )); - return Err(McpError::invalid_params( - format!( - "Invalid tool name format. Expected 'upstream:tool', got '{}'", - tool_name - ), - None, - )); - } - }; - - // Check if upstream exists in config - if !self.upstream_configs.contains_key(upstream_name) { - self.log(&format!( - "BLOCKED {} (unknown upstream '{}')", - tool_name, upstream_name - )); - return Err(McpError::invalid_params( - format!("Unknown upstream: '{}'", upstream_name), - None, - )); - } - - // Check if tool is allowed - if !self.is_tool_allowed(upstream_name, local_tool_name) { - self.log(&format!("BLOCKED {} (not in allowlist)", tool_name)); - return Err(McpError::invalid_params( - format!("Tool '{}' is not allowed by firewall policy", tool_name), - None, - )); - } - - // Ensure upstream is spawned (lazy initialization) - self.get_or_spawn_upstream(upstream_name).await?; - - // Log the allowed call - let args_summary = request - .arguments - .as_ref() - .map(|a| { - let s = serde_json::to_string(a).unwrap_or_default(); - if s.len() > 100 { - format!("{}...", &s[..100]) - } else { - s - } - }) - .unwrap_or_default(); - self.log(&format!("ALLOWED {} (args: {})", tool_name, args_summary)); - - // Forward the call to upstream - let mut upstreams = self.upstreams.write().await; - let conn = upstreams.get_mut(upstream_name).ok_or_else(|| { - McpError::internal_error("Upstream connection lost after spawn", None) - })?; - - match conn.call_tool(local_tool_name, request.arguments).await { - Ok(result) => Ok(result), - Err(e) => { - warn!( - "Upstream '{}' error calling '{}': {}", - upstream_name, local_tool_name, e - ); - Err(McpError::internal_error(e.to_string(), None)) - } - } - } -} - -// ============================================================================ -// Entry Point -// ============================================================================ - -/// Start the MCP firewall server -pub async fn run(config_path: &PathBuf) -> Result<()> { - let config = FirewallConfig::from_file(config_path)?; - - let policy = FirewallPolicy { config }; - - let firewall = McpFirewall::new(policy)?; - - firewall.log("MCP Firewall started"); - firewall.log(&format!("Upstreams ({}):", firewall.upstream_configs.len())); - for (name, config) in &firewall.upstream_configs { - firewall.log(&format!(" [{}] command: {}", name, config.command)); - firewall.log(&format!(" [{}] allowed: {:?}", name, config.allowed)); - } - firewall.log(&format!("Total tools exposed: {}", firewall.tools.len())); - - // Run as MCP server on stdio - let service = firewall.serve(stdio()).await.inspect_err(|e| { - error!("Error starting MCP firewall: {}", e); - })?; - - service - .waiting() - .await - .map_err(|e| anyhow::anyhow!("MCP firewall exited with error: {:?}", e))?; - - Ok(()) -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_upstream_config_is_tool_allowed_exact() { - let config = UpstreamConfig { - command: "test".to_string(), - args: vec![], - env: HashMap::new(), - allowed: vec!["create_incident".to_string(), "get_incident".to_string()], - spawn_timeout_secs: 30, - }; - - assert!(config.is_tool_allowed("create_incident")); - assert!(config.is_tool_allowed("get_incident")); - assert!(!config.is_tool_allowed("delete_incident")); - assert!(!config.is_tool_allowed("list_incidents")); - } - - #[test] - fn test_upstream_config_is_tool_allowed_wildcard() { - let config = UpstreamConfig { - command: "test".to_string(), - args: vec![], - env: HashMap::new(), - allowed: vec!["*".to_string()], - spawn_timeout_secs: 30, - }; - - assert!(config.is_tool_allowed("anything")); - assert!(config.is_tool_allowed("create_incident")); - assert!(config.is_tool_allowed("dangerous_delete_all")); - } - - #[test] - fn test_upstream_config_is_tool_allowed_prefix_wildcard() { - let config = UpstreamConfig { - command: "test".to_string(), - args: vec![], - env: HashMap::new(), - allowed: vec!["get_*".to_string(), "list_*".to_string()], - spawn_timeout_secs: 30, - }; - - assert!(config.is_tool_allowed("get_incident")); - assert!(config.is_tool_allowed("get_user")); - assert!(config.is_tool_allowed("list_incidents")); - assert!(!config.is_tool_allowed("create_incident")); - assert!(!config.is_tool_allowed("delete_all")); - } - - #[test] - fn test_firewall_config_from_json() { - let json = r#"{ - "upstreams": { - "icm": { - "command": "icm-mcp", - "args": ["--verbose"], - "allowed": ["create_incident", "get_incident"] - }, - "kusto": { - "command": "kusto-mcp", - "allowed": ["query"] - } - } - }"#; - - let config: FirewallConfig = serde_json::from_str(json).unwrap(); - - assert_eq!(config.upstreams.len(), 2); - assert!(config.upstreams.contains_key("icm")); - assert!(config.upstreams.contains_key("kusto")); - - let icm = &config.upstreams["icm"]; - assert_eq!(icm.command, "icm-mcp"); - assert_eq!(icm.args, vec!["--verbose"]); - assert_eq!(icm.allowed, vec!["create_incident", "get_incident"]); - assert_eq!(icm.spawn_timeout_secs, 30, "Should default to 30 seconds"); - - let kusto = &config.upstreams["kusto"]; - assert_eq!(kusto.command, "kusto-mcp"); - assert!(kusto.args.is_empty()); - assert_eq!(kusto.allowed, vec!["query"]); - assert_eq!(kusto.spawn_timeout_secs, 30, "Should default to 30 seconds"); - } - - #[test] - fn test_parse_tool_name() { - assert_eq!( - McpFirewall::parse_tool_name("icm:create_incident"), - Some(("icm", "create_incident")) - ); - assert_eq!( - McpFirewall::parse_tool_name("kusto:query"), - Some(("kusto", "query")) - ); - assert_eq!(McpFirewall::parse_tool_name("no_colon"), None); - assert_eq!( - McpFirewall::parse_tool_name("multiple:colons:here"), - Some(("multiple", "colons:here")) - ); - } - - #[test] - fn test_firewall_config_default() { - let config = FirewallConfig::default(); - assert!(config.upstreams.is_empty()); - } - - #[test] - fn test_upstream_config_timeout_custom() { - let json = r#"{ - "upstreams": { - "slow-service": { - "command": "slow-mcp", - "allowed": ["*"], - "spawn_timeout_secs": 60 - } - } - }"#; - - let config: FirewallConfig = serde_json::from_str(json).unwrap(); - let slow_service = &config.upstreams["slow-service"]; - - assert_eq!( - slow_service.spawn_timeout_secs, 60, - "Should use custom timeout of 60 seconds" - ); - } - - #[test] - fn test_upstream_config_timeout_default() { - let json = r#"{ - "upstreams": { - "normal-service": { - "command": "normal-mcp", - "allowed": ["*"] - } - } - }"#; - - let config: FirewallConfig = serde_json::from_str(json).unwrap(); - let normal_service = &config.upstreams["normal-service"]; - - assert_eq!( - normal_service.spawn_timeout_secs, 30, - "Should default to 30 seconds when not specified" - ); - } -} diff --git a/src/mcp_metadata.rs b/src/mcp_metadata.rs deleted file mode 100644 index f917c8d..0000000 --- a/src/mcp_metadata.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! MCP Metadata - Bundled tool definitions for agency MCPs -//! -//! This module provides access to pre-discovered MCP tool metadata that is -//! embedded at compile time. The metadata is refreshed by running: -//! -//! ```bash -//! # On Windows -//! ./refresh-mcp-metadata.ps1 -//! -//! # On Linux/macOS -//! ./refresh-mcp-metadata.sh -//! ``` -//! -//! The scripts query each built-in agency MCP and save the tool definitions -//! to `mcp-metadata.json`, which is then embedded into the binary. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Bundled MCP metadata (embedded at compile time) -const BUNDLED_METADATA: &str = include_str!("../mcp-metadata.json"); - -/// Metadata for a single tool -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolMetadata { - /// Tool name (without namespace prefix) - pub name: String, - /// Human-readable description - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// JSON schema for input parameters - #[serde(skip_serializing_if = "Option::is_none")] - pub input_schema: Option, -} - -/// Metadata for an MCP server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpMetadata { - /// Server name/identifier - pub name: String, - /// Whether this is a built-in agency MCP - #[serde(default)] - pub builtin: bool, - /// Available tools - #[serde(default)] - pub tools: Vec, - /// When this metadata was last refreshed (ISO 8601) - #[serde(skip_serializing_if = "Option::is_none")] - pub refreshed_at: Option, - /// Error message if discovery failed - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// Collection of MCP metadata -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpMetadataFile { - /// Schema version for forward compatibility - pub version: String, - /// When this file was generated - pub generated_at: String, - /// Metadata for each MCP server - pub mcps: HashMap, -} - -impl McpMetadataFile { - /// Load the bundled metadata (embedded at compile time) - pub fn bundled() -> Self { - serde_json::from_str(BUNDLED_METADATA) - .expect("Bundled mcp-metadata.json should be valid JSON") - } - - /// Get metadata for a specific MCP - pub fn get(&self, mcp_name: &str) -> Option<&McpMetadata> { - self.mcps.get(mcp_name) - } - - /// Get tools for a specific MCP - pub fn get_tools(&self, mcp_name: &str) -> Option<&[ToolMetadata]> { - self.mcps.get(mcp_name).map(|m| m.tools.as_slice()) - } - - /// Check if a tool exists for an MCP - pub fn has_tool(&self, mcp_name: &str, tool_name: &str) -> bool { - self.mcps - .get(mcp_name) - .map(|m| m.tools.iter().any(|t| t.name == tool_name)) - .unwrap_or(false) - } - - /// Get all known MCP names - pub fn mcp_names(&self) -> Vec<&str> { - self.mcps.keys().map(|s| s.as_str()).collect() - } - - /// Get all built-in MCP names (sorted alphabetically) - pub fn builtin_mcp_names(&self) -> Vec<&str> { - let mut names: Vec<&str> = self - .mcps - .iter() - .filter(|(_, m)| m.builtin) - .map(|(k, _)| k.as_str()) - .collect(); - names.sort(); - names - } - - /// Get all tool names for an MCP (useful for validation) - pub fn tool_names(&self, mcp_name: &str) -> Vec<&str> { - self.mcps - .get(mcp_name) - .map(|m| m.tools.iter().map(|t| t.name.as_str()).collect()) - .unwrap_or_default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bundled_metadata_loads() { - let metadata = McpMetadataFile::bundled(); - assert_eq!(metadata.version, "1.0"); - // Should have the known built-in MCPs - assert!(metadata.mcps.contains_key("ado")); - assert!(metadata.mcps.contains_key("icm")); - assert!(metadata.mcps.contains_key("kusto")); - } - - #[test] - fn test_get_mcp() { - let metadata = McpMetadataFile::bundled(); - let ado = metadata.get("ado"); - assert!(ado.is_some()); - assert!(ado.unwrap().builtin); - } - - #[test] - fn test_mcp_names() { - let metadata = McpMetadataFile::bundled(); - let names = metadata.mcp_names(); - assert!(names.contains(&"ado")); - assert!(names.contains(&"icm")); - } -} diff --git a/tests/mcp_firewall_tests.rs b/tests/mcp_firewall_tests.rs deleted file mode 100644 index b65f025..0000000 --- a/tests/mcp_firewall_tests.rs +++ /dev/null @@ -1,281 +0,0 @@ -use std::io::{BufRead, Write}; -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -/// Guard that kills the child process on drop (even on panic) -struct FirewallGuard { - child: Child, - #[allow(dead_code)] - stderr_thread: Option>, -} - -impl Drop for FirewallGuard { - fn drop(&mut self) { - self.child.kill().ok(); - self.child.wait().ok(); - } -} - -/// Helper to create a temporary config file -fn create_config_file(config: &str) -> (tempfile::TempDir, std::path::PathBuf) { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join("firewall-config.json"); - std::fs::write(&config_path, config).unwrap(); - (temp_dir, config_path) -} - -/// Helper to start the firewall with a config file -fn start_firewall(config_path: &std::path::PathBuf) -> FirewallGuard { - let binary_path = env!("CARGO_BIN_EXE_ado-aw"); - - let mut cmd = Command::new(binary_path); - cmd.arg("mcp-firewall"); - cmd.arg("--config").arg(config_path); - - cmd.stdin(Stdio::piped()); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - - let mut child = cmd.spawn().expect("Failed to start firewall"); - - // Spawn thread to consume stderr - let stderr = child.stderr.take().expect("Failed to capture stderr"); - let stderr_thread = std::thread::spawn(move || { - let reader = std::io::BufReader::new(stderr); - for line in reader.lines() { - if let Ok(line) = line { - eprintln!("[firewall stderr] {}", line); - } else { - break; - } - } - }); - - // Give the firewall a moment to start - std::thread::sleep(Duration::from_millis(200)); - - FirewallGuard { - child, - stderr_thread: Some(stderr_thread), - } -} - -/// Send a JSON-RPC request and get response -fn send_jsonrpc( - child: &mut Child, - method: &str, - params: Option, -) -> serde_json::Value { - static REQUEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - let id = REQUEST_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - - let request = if let Some(p) = params { - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": p - }) - } else { - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": method - }) - }; - - let stdin = child.stdin.as_mut().expect("Failed to get stdin"); - let stdout = child.stdout.as_mut().expect("Failed to get stdout"); - - writeln!(stdin, "{}", serde_json::to_string(&request).unwrap()).unwrap(); - stdin.flush().unwrap(); - - let mut reader = std::io::BufReader::new(stdout); - let mut response_line = String::new(); - reader.read_line(&mut response_line).unwrap(); - - serde_json::from_str(&response_line).unwrap() -} - -#[test] -fn test_firewall_starts_with_empty_config() { - let config = r#"{"upstreams": {}}"#; - let (_temp_dir, config_path) = create_config_file(config); - - let mut guard = start_firewall(&config_path); - - // Initialize the MCP connection - let init_params = serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { - "name": "test-client", - "version": "1.0" - } - }); - - let response = send_jsonrpc(&mut guard.child, "initialize", Some(init_params)); - - assert!( - response.get("result").is_some(), - "Should get initialize result" - ); - assert!( - response["result"]["serverInfo"]["name"].as_str().is_some(), - "Should have server info" - ); -} - -#[test] -fn test_firewall_lists_no_tools_with_empty_config() { - let config = r#"{"upstreams": {}}"#; - let (_temp_dir, config_path) = create_config_file(config); - - let mut guard = start_firewall(&config_path); - - // Initialize first - let init_params = serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { "name": "test", "version": "1.0" } - }); - send_jsonrpc(&mut guard.child, "initialize", Some(init_params)); - - // Send initialized notification (no response expected, but we need to send it) - let stdin = guard.child.stdin.as_mut().unwrap(); - writeln!( - stdin, - r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"# - ) - .unwrap(); - stdin.flush().unwrap(); - - // List tools - let response = send_jsonrpc(&mut guard.child, "tools/list", None); - - assert!( - response.get("result").is_some(), - "Should get tools/list result" - ); - let tools = response["result"]["tools"] - .as_array() - .expect("tools should be array"); - assert!(tools.is_empty(), "Should have no tools with empty config"); -} - -#[test] -fn test_firewall_rejects_unknown_tool() { - let config = r#"{"upstreams": {}}"#; - let (_temp_dir, config_path) = create_config_file(config); - - let mut guard = start_firewall(&config_path); - - // Initialize - let init_params = serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { "name": "test", "version": "1.0" } - }); - send_jsonrpc(&mut guard.child, "initialize", Some(init_params)); - - let stdin = guard.child.stdin.as_mut().unwrap(); - writeln!( - stdin, - r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"# - ) - .unwrap(); - stdin.flush().unwrap(); - - // Try to call a tool that doesn't exist - let call_params = serde_json::json!({ - "name": "unknown:tool", - "arguments": {} - }); - let response = send_jsonrpc(&mut guard.child, "tools/call", Some(call_params)); - - assert!( - response.get("error").is_some(), - "Should get error for unknown tool" - ); - let error = &response["error"]; - assert!( - error["message"] - .as_str() - .unwrap_or("") - .contains("Unknown upstream"), - "Error should mention unknown upstream, got: {:?}", - error - ); -} - -#[test] -fn test_firewall_rejects_invalid_tool_format() { - let config = r#"{"upstreams": {}}"#; - let (_temp_dir, config_path) = create_config_file(config); - - let mut guard = start_firewall(&config_path); - - // Initialize - let init_params = serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { "name": "test", "version": "1.0" } - }); - send_jsonrpc(&mut guard.child, "initialize", Some(init_params)); - - let stdin = guard.child.stdin.as_mut().unwrap(); - writeln!( - stdin, - r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"# - ) - .unwrap(); - stdin.flush().unwrap(); - - // Try to call a tool without namespace - let call_params = serde_json::json!({ - "name": "no_colon_here", - "arguments": {} - }); - let response = send_jsonrpc(&mut guard.child, "tools/call", Some(call_params)); - - assert!( - response.get("error").is_some(), - "Should get error for invalid format" - ); - let error = &response["error"]; - assert!( - error["message"] - .as_str() - .unwrap_or("") - .contains("Invalid tool name format"), - "Error should mention invalid format, got: {:?}", - error - ); -} - -#[test] -fn test_config_parsing() { - // Test that we can parse a realistic config - let config = r#"{ - "upstreams": { - "icm": { - "command": "icm-mcp", - "args": ["--verbose"], - "env": {"ICM_TOKEN": "secret"}, - "allowed": ["create_incident", "get_*"] - }, - "kusto": { - "command": "kusto-mcp", - "allowed": ["query"] - } - } - }"#; - - let parsed: serde_json::Value = serde_json::from_str(config).unwrap(); - - assert_eq!(parsed["upstreams"]["icm"]["command"], "icm-mcp"); - assert_eq!(parsed["upstreams"]["icm"]["args"][0], "--verbose"); - assert_eq!(parsed["upstreams"]["icm"]["allowed"][0], "create_incident"); - assert_eq!(parsed["upstreams"]["icm"]["allowed"][1], "get_*"); - assert_eq!(parsed["upstreams"]["kusto"]["allowed"][0], "query"); -} From 79d54a65064aa4897b39630e0ce8cf0c4ca85bf8 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 8 Mar 2026 23:06:24 +0000 Subject: [PATCH 02/19] feat: add MCP Gateway (MCPG) support with SafeOutputs HTTP server Add infrastructure for the gh-aw-mcpg gateway integration: - Add MCPG_VERSION and MCPG_IMAGE constants in common.rs - Add {{ mcpg_version }} and {{ mcpg_image }} template markers - Replace generate_firewall_config() with generate_mcpg_config() that produces MCPG-compatible JSON (mcpServers + gateway sections) - SafeOutputs always included as HTTP backend via host.docker.internal - Custom MCPs (with command:) become stdio servers in MCPG config - Add host.docker.internal to CORE_ALLOWED_HOSTS for AWF container to reach host-side MCPG - Add mcp-http subcommand: serves SafeOutputs over HTTP using rmcp's StreamableHttpService with axum, API key auth, and health endpoint - Add axum and rmcp transport-streamable-http-server dependencies - Remove terminal_size dependency (no longer used) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 385 ++++++++++++++++++++++++++++++++++++-- Cargo.toml | 4 +- src/allowed_hosts.rs | 2 + src/compile/standalone.rs | 281 ++++++++++++++++------------ src/mcp.rs | 109 +++++++++++ 5 files changed, 642 insertions(+), 139 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7415195..30d5fd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "axum", "chrono", "clap", "dirs", @@ -22,7 +23,6 @@ dependencies = [ "serde_json", "serde_yaml", "tempfile", - "terminal_size", "tokio", ] @@ -123,6 +123,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -445,6 +497,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -586,8 +644,21 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", "wasip2", + "wasip3", ] [[package]] @@ -609,6 +680,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -660,6 +740,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -674,6 +760,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -845,6 +932,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -879,7 +972,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -959,6 +1054,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" @@ -1008,6 +1109,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.6" @@ -1197,6 +1304,25 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -1221,6 +1347,41 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1357,18 +1518,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5947688160b56fb6c827e3c20a72c90392a1d7e9dec74749197aa1780ac42ca" dependencies = [ "base64", + "bytes", "chrono", "futures", + "http", + "http-body", + "http-body-util", "paste", "pin-project-lite", + "rand", "rmcp-macros", "schemars", "serde", "serde_json", + "sse-stream", "thiserror", "tokio", + "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -1575,6 +1745,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1659,6 +1840,19 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1742,16 +1936,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "terminal_size" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" -dependencies = [ - "rustix", - "windows-sys 0.60.2", -] - [[package]] name = "thiserror" version = "2.0.18" @@ -1839,6 +2023,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1865,6 +2060,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1903,6 +2099,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1952,6 +2149,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1988,6 +2191,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2018,6 +2232,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -2077,6 +2300,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -2340,6 +2597,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -2370,6 +2709,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 78254e2..be2d083 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" serde_json = "1.0.149" schemars = "1.2" -rmcp = { version = "0.8.0", features = ["server", "transport-io"] } +rmcp = { version = "0.8.0", features = ["server", "transport-io", "transport-streamable-http-server"] } reqwest = { version = "0.12", features = ["json"] } tempfile = "3" tokio = { version = "1.43", features = ["full"] } @@ -21,4 +21,4 @@ log = "0.4" env_logger = "0.11" regex-lite = "0.1" inquire = { version = "0.9.2", features = ["editor"] } -terminal_size = "0.4.3" +axum = { version = "0.8.8", features = ["tokio"] } diff --git a/src/allowed_hosts.rs b/src/allowed_hosts.rs index 417ac81..59e1781 100644 --- a/src/allowed_hosts.rs +++ b/src/allowed_hosts.rs @@ -53,6 +53,8 @@ pub static CORE_ALLOWED_HOSTS: &[&str] = &[ "rt.services.visualstudio.com", // ===== Agency / Copilot configuration ===== "config.edge.skype.com", + // ===== MCP Gateway (host-side MCPG accessible from AWF container) ===== + "host.docker.internal", // Note: 168.63.129.16 (Azure DNS) is handled separately as it's an IP ]; diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 21dec18..44a982c 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -14,15 +14,15 @@ use std::path::Path; use super::Compiler; use super::common::{ - self, AWF_VERSION, DEFAULT_POOL, compute_effective_workspace, generate_copilot_params, - generate_cancel_previous_builds, generate_checkout_self, generate_checkout_steps, - generate_ci_trigger, generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger, - generate_repositories, generate_schedule, generate_source_path, generate_working_directory, - replace_with_indent, sanitize_filename, + self, AWF_VERSION, DEFAULT_POOL, MCPG_IMAGE, MCPG_VERSION, compute_effective_workspace, + generate_copilot_params, generate_cancel_previous_builds, generate_checkout_self, + generate_checkout_steps, generate_ci_trigger, generate_pipeline_path, + generate_pipeline_resources, generate_pr_trigger, generate_repositories, generate_schedule, + generate_source_path, generate_working_directory, replace_with_indent, sanitize_filename, }; use super::types::{FrontMatter, McpConfig}; use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts}; -use crate::mcp_firewall::{FirewallConfig, UpstreamConfig}; +use serde::Serialize; use std::collections::HashSet; /// Standalone pipeline compiler. @@ -124,6 +124,8 @@ impl Compiler for StandaloneCompiler { let replacements: Vec<(&str, &str)> = vec![ ("{{ compiler_version }}", compiler_version), ("{{ firewall_version }}", AWF_VERSION), + ("{{ mcpg_version }}", MCPG_VERSION), + ("{{ mcpg_image }}", MCPG_IMAGE), ("{{ pool }}", &pool), ("{{ setup_job }}", &setup_job), ("{{ teardown_job }}", &teardown_job), @@ -158,19 +160,19 @@ impl Compiler for StandaloneCompiler { replace_with_indent(&yaml, placeholder, replacement) }); - // Generate MCP firewall config JSON - let firewall_config_json = if !front_matter.mcp_servers.is_empty() { - let config = generate_firewall_config(front_matter); + // Generate MCPG config JSON + let mcpg_config_json = if !front_matter.mcp_servers.is_empty() { + let config = generate_mcpg_config(front_matter); serde_json::to_string_pretty(&config) - .unwrap_or_else(|_| r#"{"upstreams":{}}"#.to_string()) + .unwrap_or_else(|_| r#"{"mcpServers":{}}"#.to_string()) } else { - r#"{"upstreams":{}}"#.to_string() + r#"{"mcpServers":{}}"#.to_string() }; let pipeline_yaml = replace_with_indent( &pipeline_yaml, - "{{ firewall_config }}", - &firewall_config_json, + "{{ mcpg_config }}", + &mcpg_config_json, ); Ok(pipeline_yaml) @@ -331,9 +333,76 @@ fn generate_agentic_depends_on(setup_steps: &[serde_yaml::Value]) -> String { } } -/// Generate MCP firewall configuration from front matter -pub fn generate_firewall_config(front_matter: &FrontMatter) -> FirewallConfig { - let mut upstreams = HashMap::new(); +/// MCPG server configuration for a single MCP upstream. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct McpgServerConfig { + /// Server type: "stdio" for command-based, "http" for HTTP backends + #[serde(rename = "type")] + pub server_type: String, + /// Command to run (for stdio type) + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + /// Command arguments (for stdio type) + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option>, + /// URL for HTTP backends + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + /// HTTP headers (e.g., Authorization) + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + /// Environment variables for the server process + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option>, + /// Tool allow-list (if empty or absent, all tools are allowed) + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, +} + +/// MCPG gateway configuration. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct McpgGatewayConfig { + pub port: u16, + pub domain: String, + pub api_key: String, + pub payload_dir: String, +} + +/// Top-level MCPG configuration. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct McpgConfig { + pub mcp_servers: HashMap, + pub gateway: McpgGatewayConfig, +} + +/// Generate MCPG configuration from front matter. +/// +/// Converts the front matter `mcp-servers` definitions into MCPG-compatible JSON. +/// SafeOutputs is always included as an HTTP backend. Custom MCPs with explicit +/// `command:` are included as stdio servers. +pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { + let mut mcp_servers = HashMap::new(); + + // SafeOutputs is always included as an HTTP backend. + // The actual URL/key are replaced at runtime by the pipeline template. + mcp_servers.insert( + "safeoutputs".to_string(), + McpgServerConfig { + server_type: "http".to_string(), + command: None, + args: None, + url: Some("http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp".to_string()), + headers: Some(HashMap::from([( + "Authorization".to_string(), + "Bearer ${SAFE_OUTPUTS_API_KEY}".to_string(), + )])), + env: None, + tools: None, + }, + ); for (name, config) in &front_matter.mcp_servers { let (is_enabled, options) = match config { @@ -345,66 +414,53 @@ pub fn generate_firewall_config(front_matter: &FrontMatter) -> FirewallConfig { continue; } - let upstream = if let Some(opts) = options { + if let Some(opts) = options { if let Some(command) = &opts.command { - // Custom MCP with explicit command - UpstreamConfig { - command: command.clone(), - args: opts.args.clone(), - env: opts.env.clone(), - allowed: if opts.allowed.is_empty() { - vec!["*".to_string()] - } else { - opts.allowed.clone() - }, - spawn_timeout_secs: 30, - } - } else if common::is_builtin_mcp(name) { - // Built-in MCP with options - let mut args = vec!["mcp".to_string(), name.clone()]; - args.extend(opts.args.clone()); - - UpstreamConfig { - command: "agency".to_string(), - args, - env: opts.env.clone(), - allowed: if opts.allowed.is_empty() { - vec!["*".to_string()] - } else { - opts.allowed.clone() + // Custom MCP with explicit command → stdio server + let env = if opts.env.is_empty() { + None + } else { + Some(opts.env.clone()) + }; + let tools = if opts.allowed.is_empty() { + None + } else { + Some(opts.allowed.clone()) + }; + mcp_servers.insert( + name.clone(), + McpgServerConfig { + server_type: "stdio".to_string(), + command: Some(command.clone()), + args: Some(opts.args.clone()), + url: None, + headers: None, + env, + tools, }, - spawn_timeout_secs: 30, - } + ); } else { log::warn!( - "MCP '{}' has no command and is not a built-in - skipping", + "MCP '{}' has no command — skipping (no built-in MCPs available)", name ); - continue; - } - } else if common::is_builtin_mcp(name) { - // Built-in MCP with simple enablement - UpstreamConfig { - command: "agency".to_string(), - args: vec!["mcp".to_string(), name.clone()], - env: HashMap::new(), - allowed: vec!["*".to_string()], - spawn_timeout_secs: 30, } } else { log::warn!( - "MCP '{}' is not a built-in and has no command - skipping", + "MCP '{}' has no command — skipping (no built-in MCPs available)", name ); - continue; - }; - - upstreams.insert(name.clone(), upstream); + } } - FirewallConfig { - upstreams, - metadata_path: None, + McpgConfig { + mcp_servers, + gateway: McpgGatewayConfig { + port: 80, + domain: "host.docker.internal".to_string(), + api_key: "${MCP_GATEWAY_API_KEY}".to_string(), + payload_dir: "/tmp/gh-aw/mcp-payloads".to_string(), + }, } } @@ -513,39 +569,16 @@ mod tests { } #[test] - fn test_generate_firewall_config_builtin_simple_enabled() { - let mut fm = minimal_front_matter(); - fm.mcp_servers - .insert("ado".to_string(), McpConfig::Enabled(true)); - let config = generate_firewall_config(&fm); - let upstream = config.upstreams.get("ado").unwrap(); - assert_eq!(upstream.command, "agency"); - assert_eq!(upstream.args, vec!["mcp", "ado"]); - assert_eq!(upstream.allowed, vec!["*"]); - } - - #[test] - fn test_generate_firewall_config_builtin_with_allowed_list() { - let mut fm = minimal_front_matter(); - fm.mcp_servers.insert( - "icm".to_string(), - McpConfig::WithOptions(McpOptions { - allowed: vec!["create_incident".to_string(), "get_incident".to_string()], - ..Default::default() - }), - ); - let config = generate_firewall_config(&fm); - let upstream = config.upstreams.get("icm").unwrap(); - assert_eq!(upstream.command, "agency"); - assert_eq!(upstream.args, vec!["mcp", "icm"]); - assert_eq!( - upstream.allowed, - vec!["create_incident".to_string(), "get_incident".to_string()] - ); + fn test_generate_mcpg_config_always_includes_safeoutputs() { + let fm = minimal_front_matter(); + let config = generate_mcpg_config(&fm); + let so = config.mcp_servers.get("safeoutputs").unwrap(); + assert_eq!(so.server_type, "http"); + assert!(so.url.as_ref().unwrap().contains("host.docker.internal")); } #[test] - fn test_generate_firewall_config_custom_mcp() { + fn test_generate_mcpg_config_custom_mcp() { let mut fm = minimal_front_matter(); fm.mcp_servers.insert( "my-tool".to_string(), @@ -556,52 +589,52 @@ mod tests { ..Default::default() }), ); - let config = generate_firewall_config(&fm); - let upstream = config.upstreams.get("my-tool").unwrap(); - assert_eq!(upstream.command, "node"); - assert_eq!(upstream.args, vec!["server.js"]); - assert_eq!(upstream.allowed, vec!["do_thing"]); - } - - #[test] - fn test_generate_firewall_config_custom_mcp_empty_allowed_defaults_to_wildcard() { - let mut fm = minimal_front_matter(); - fm.mcp_servers.insert( - "my-tool".to_string(), - McpConfig::WithOptions(McpOptions { - command: Some("python".to_string()), - allowed: vec![], - ..Default::default() - }), + let config = generate_mcpg_config(&fm); + let server = config.mcp_servers.get("my-tool").unwrap(); + assert_eq!(server.server_type, "stdio"); + assert_eq!(server.command.as_ref().unwrap(), "node"); + assert_eq!(server.args.as_ref().unwrap(), &vec!["server.js"]); + assert_eq!( + server.tools.as_ref().unwrap(), + &vec!["do_thing".to_string()] ); - let config = generate_firewall_config(&fm); - let upstream = config.upstreams.get("my-tool").unwrap(); - assert_eq!(upstream.allowed, vec!["*"]); } #[test] - fn test_generate_firewall_config_unknown_non_builtin_skipped() { - // An MCP that is neither built-in nor has a command should be skipped + fn test_generate_mcpg_config_mcp_without_command_skipped() { let mut fm = minimal_front_matter(); + // An MCP with no command should be skipped (no built-in MCPs) fm.mcp_servers .insert("phantom".to_string(), McpConfig::Enabled(true)); - let config = generate_firewall_config(&fm); - assert!(!config.upstreams.contains_key("phantom")); + let config = generate_mcpg_config(&fm); + assert!(!config.mcp_servers.contains_key("phantom")); + // safeoutputs is always present + assert!(config.mcp_servers.contains_key("safeoutputs")); } #[test] - fn test_generate_firewall_config_disabled_mcp_skipped() { + fn test_generate_mcpg_config_disabled_mcp_skipped() { let mut fm = minimal_front_matter(); fm.mcp_servers - .insert("ado".to_string(), McpConfig::Enabled(false)); - let config = generate_firewall_config(&fm); - assert!(!config.upstreams.contains_key("ado")); + .insert("my-tool".to_string(), McpConfig::Enabled(false)); + let config = generate_mcpg_config(&fm); + assert!(!config.mcp_servers.contains_key("my-tool")); + } + + #[test] + fn test_generate_mcpg_config_empty_mcp_servers() { + let fm = minimal_front_matter(); + let config = generate_mcpg_config(&fm); + // Only safeoutputs should be present + assert_eq!(config.mcp_servers.len(), 1); + assert!(config.mcp_servers.contains_key("safeoutputs")); } #[test] - fn test_generate_firewall_config_empty_mcp_servers() { + fn test_generate_mcpg_config_gateway_defaults() { let fm = minimal_front_matter(); - let config = generate_firewall_config(&fm); - assert!(config.upstreams.is_empty()); + let config = generate_mcpg_config(&fm); + assert_eq!(config.gateway.port, 80); + assert_eq!(config.gateway.domain, "host.docker.internal"); } } diff --git a/src/mcp.rs b/src/mcp.rs index 4cf4525..d4e3965 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -434,6 +434,115 @@ pub async fn run(output_directory: &str, bounding_directory: &str) -> Result<()> Ok(()) } +/// Run SafeOutputs MCP server over HTTP using the Streamable HTTP protocol. +/// +/// This is used for MCPG integration: the gateway connects to this server as an +/// HTTP backend and proxies tool calls from the agent. +pub async fn run_http( + output_directory: &str, + bounding_directory: &str, + port: u16, + api_key: Option<&str>, +) -> Result<()> { + use axum::Router; + use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::local::LocalSessionManager, + }; + use std::sync::Arc; + + let bounding = bounding_directory.to_string(); + let output = output_directory.to_string(); + + // Generate or use provided API key + let api_key = api_key + .map(|k| k.to_string()) + .unwrap_or_else(|| { + use std::time::{SystemTime, UNIX_EPOCH}; + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{:x}", seed) + }); + + info!("Starting SafeOutputs HTTP server on port {}", port); + + let config = StreamableHttpServerConfig { + sse_keep_alive: Some(std::time::Duration::from_secs(15)), + stateful_mode: true, + }; + + let session_manager = Arc::new(LocalSessionManager::default()); + + let bounding_clone = bounding.clone(); + let output_clone = output.clone(); + let mcp_service = StreamableHttpService::new( + move || { + let bounding = bounding_clone.clone(); + let output = output_clone.clone(); + let rt = tokio::runtime::Handle::current(); + let safe_outputs = rt.block_on(async { + SafeOutputs::new(&bounding, &output).await + }).map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(safe_outputs) + }, + session_manager, + config, + ); + + // Wrap with API key auth middleware + let expected_key = api_key.clone(); + let app = Router::new() + .route("/health", axum::routing::get(|| async { "ok" })) + .route( + "/mcp", + axum::routing::post(axum::routing::any_service(mcp_service.clone())) + .get(axum::routing::any_service(mcp_service.clone())) + .delete(axum::routing::any_service(mcp_service)), + ) + .layer(axum::middleware::from_fn(move |req: axum::extract::Request, next: axum::middleware::Next| { + let expected = expected_key.clone(); + async move { + // Skip auth for health endpoint + if req.uri().path() == "/health" { + return next.run(req).await; + } + + // Check Bearer token + if let Some(auth) = req.headers().get("authorization") { + if let Ok(auth_str) = auth.to_str() { + if auth_str == format!("Bearer {}", expected) { + return next.run(req).await; + } + } + } + + axum::response::Response::builder() + .status(401) + .body(axum::body::Body::from("Unauthorized")) + .unwrap() + } + })); + + let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); + let listener = tokio::net::TcpListener::bind(addr).await?; + info!("SafeOutputs HTTP server listening on {}", addr); + + // Print connection info for pipeline capture + println!("SAFE_OUTPUTS_PORT={}", port); + println!("SAFE_OUTPUTS_API_KEY={}", api_key); + + axum::serve(listener, app) + .with_graceful_shutdown(async { + tokio::signal::ctrl_c().await.ok(); + info!("SafeOutputs HTTP server shutting down"); + }) + .await?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From 8407d3521bc80806b776bd6c654e6c6fc9077630 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 8 Mar 2026 23:06:35 +0000 Subject: [PATCH 03/19] feat: rewrite base.yml template for MCPG integration Replace the legacy MCP firewall pipeline steps with MCPG architecture: - Replace 'Prepare MCP firewall config' with 'Prepare MCPG config' - Replace stdio MCP config (safeoutputs + mcp-firewall) with HTTP config pointing copilot to MCPG via host.docker.internal - Add 'Start SafeOutputs HTTP server' step (background process on host) - Add 'Start MCP Gateway (MCPG)' step (Docker container on host network) - Add 'Stop MCPG and SafeOutputs' cleanup step (condition: always) - Add --enable-host-access to AWF invocation for container-to-host access - Pre-pull MCPG Docker image alongside AWF images - Update step display names and comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- templates/base.yml | 125 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 98 insertions(+), 27 deletions(-) diff --git a/templates/base.yml b/templates/base.yml index b913366..d4a021f 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -79,17 +79,17 @@ jobs: - bash: | mkdir -p "$(Agent.TempDirectory)/staging" - # Write MCP firewall configuration to a file - cat > "$(Agent.TempDirectory)/staging/mcp-firewall-config.json" << 'MCP_FIREWALL_EOF' - {{ firewall_config }} - MCP_FIREWALL_EOF + # Write MCPG (MCP Gateway) configuration to a file + cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' + {{ mcpg_config }} + MCPG_CONFIG_EOF - echo "MCP firewall config:" - cat "$(Agent.TempDirectory)/staging/mcp-firewall-config.json" + echo "MCPG config:" + cat "$(Agent.TempDirectory)/staging/mcpg-config.json" # Validate JSON - python3 -m json.tool "$(Agent.TempDirectory)/staging/mcp-firewall-config.json" > /dev/null && echo "JSON is valid" - displayName: "Prepare MCP firewall config" + python3 -m json.tool "$(Agent.TempDirectory)/staging/mcpg-config.json" > /dev/null && echo "JSON is valid" + displayName: "Prepare MCPG config" - bash: | mkdir -p "$HOME/.copilot" @@ -110,28 +110,17 @@ jobs: cp "$AGENTIC_PIPELINES_PATH" /tmp/awf-tools/ado-aw chmod +x /tmp/awf-tools/ado-aw - # Copy MCP firewall config to /tmp - cp "$(Agent.TempDirectory)/staging/mcp-firewall-config.json" /tmp/awf-tools/staging/mcp-firewall-config.json + # Copy MCPG config to /tmp + cp "$(Agent.TempDirectory)/staging/mcpg-config.json" /tmp/awf-tools/staging/mcpg-config.json - # Generate MCP config pointing to /tmp paths (accessible inside AWF container) + # Generate MCP config for copilot CLI pointing to MCPG gateway on host + # The agent inside AWF reaches MCPG via host.docker.internal cat > /tmp/awf-tools/mcp-config.json << EOF { "mcpServers": { - "safeoutputs": { - "type": "stdio", - "tools": [ - "*" - ], - "command": "/tmp/awf-tools/ado-aw", - "args": ["mcp", "/tmp/awf-tools/staging", "{{ working_directory }}"] - }, - "mcp-firewall": { - "type": "stdio", - "tools": [ - "*" - ], - "command": "/tmp/awf-tools/ado-aw", - "args": ["mcp-firewall", "--config", "/tmp/awf-tools/staging/mcp-firewall-config.json"] + "mcpg": { + "type": "http", + "url": "http://host.docker.internal:80/mcp" } } } @@ -195,10 +184,74 @@ jobs: - bash: | docker pull ghcr.io/github/gh-aw-firewall/squid:latest docker pull ghcr.io/github/gh-aw-firewall/agent:latest - displayName: "Pre-pull AWF container images" + docker pull {{ mcpg_image }}:v{{ mcpg_version }} + displayName: "Pre-pull AWF and MCPG container images" {{ prepare_steps }} + # Start SafeOutputs HTTP server on host (MCPG proxies to it) + - bash: | + SAFE_OUTPUTS_PORT=8100 + SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + + # Start SafeOutputs as HTTP server in the background + nohup /tmp/awf-tools/ado-aw mcp-http \ + --port "$SAFE_OUTPUTS_PORT" \ + --api-key "$SAFE_OUTPUTS_API_KEY" \ + "/tmp/awf-tools/staging" \ + "{{ working_directory }}" \ + > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & + SAFE_OUTPUTS_PID=$! + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" + echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + + # Wait for server to be ready + for i in $(seq 1 30); do + if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + echo "SafeOutputs HTTP server is ready" + break + fi + sleep 1 + done + displayName: "Start SafeOutputs HTTP server" + + # Start MCP Gateway (MCPG) on host + - bash: | + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "##vso[task.setvariable variable=MCP_GATEWAY_API_KEY;issecret=true]$MCP_GATEWAY_API_KEY" + + # Substitute runtime values into MCPG config + MCPG_CONFIG=$(cat /tmp/awf-tools/staging/mcpg-config.json \ + | sed "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ + | sed "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + | sed "s|\${MCP_GATEWAY_API_KEY}|$MCP_GATEWAY_API_KEY|g") + + echo "Starting MCPG with config:" + echo "$MCPG_CONFIG" | python3 -m json.tool + + # Start MCPG Docker container on host network + echo "$MCPG_CONFIG" | docker run -i --rm \ + --name mcpg \ + --network host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e MCP_GATEWAY_API_KEY="$MCP_GATEWAY_API_KEY" \ + {{ mcpg_image }}:v{{ mcpg_version }} & + MCPG_PID=$! + echo "##vso[task.setvariable variable=MCPG_PID]$MCPG_PID" + echo "MCPG started (PID: $MCPG_PID)" + + # Wait for MCPG to be ready + for i in $(seq 1 30); do + if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + echo "MCPG is ready" + break + fi + sleep 1 + done + displayName: "Start MCP Gateway (MCPG)" + # Network isolation via AWF (Agentic Workflow Firewall) - bash: | set -o pipefail @@ -210,12 +263,15 @@ jobs: echo "Allowed domains: {{ allowed_domains }}" # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. + # --enable-host-access allows the AWF container to reach host services + # (MCPG and SafeOutputs) via host.docker.internal. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. sudo -E "$(Pipeline.Workspace)/awf/awf" \ --allow-domains {{ allowed_domains }} \ --skip-pull \ --env-all \ + --enable-host-access \ --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ @@ -251,6 +307,21 @@ jobs: displayName: "Collect safe outputs from AWF container" condition: always() + - bash: | + # Stop MCPG container + echo "Stopping MCPG..." + docker stop mcpg 2>/dev/null || true + echo "MCPG stopped" + + # Stop SafeOutputs HTTP server + if [ -n "$(SAFE_OUTPUTS_PID)" ]; then + echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." + kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true + echo "SafeOutputs stopped" + fi + displayName: "Stop MCPG and SafeOutputs" + condition: always() + {{ finalize_steps }} - bash: | From c48fe16391be5c1ef14bd19a649edeae6a627681 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 8 Mar 2026 23:06:43 +0000 Subject: [PATCH 04/19] test: update compiler tests for MCPG migration - Add assertions for MCPG Docker image reference in compiled output - Add assertions for host.docker.internal and --enable-host-access - Add assertions verifying no legacy mcp-firewall references - Add template structure checks for mcpg_config, mcpg_image, mcpg_version markers - Verify template no longer contains mcp-firewall-config or MCP_FIREWALL_EOF - Update fixture test to not require built-in MCP assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/compiler_tests.rs | 55 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 5416c21..3538841 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -166,6 +166,34 @@ fn test_compiled_yaml_structure() { template_content.contains("{{ firewall_version }}"), "Template should contain firewall_version marker" ); + + // Verify MCPG integration + assert!( + template_content.contains("{{ mcpg_config }}"), + "Template should contain mcpg_config marker" + ); + assert!( + template_content.contains("{{ mcpg_image }}"), + "Template should contain mcpg_image marker" + ); + assert!( + template_content.contains("{{ mcpg_version }}"), + "Template should contain mcpg_version marker" + ); + assert!( + template_content.contains("--enable-host-access"), + "Template should include --enable-host-access for MCPG" + ); + + // Verify no legacy mcp-firewall references in template + assert!( + !template_content.contains("mcp-firewall-config"), + "Template should not reference legacy mcp-firewall config" + ); + assert!( + !template_content.contains("MCP_FIREWALL_EOF"), + "Template should not contain legacy firewall heredoc" + ); } /// Test that the example file is valid and can be parsed @@ -313,8 +341,7 @@ fn test_fixture_complete_agent() { "Should have mcp-servers" ); - // Verify it has both built-in and custom MCPs - assert!(content.contains("ado: true"), "Should have built-in MCP"); + // Verify it has MCP configuration and custom MCPs assert!(content.contains("command:"), "Should have custom MCP"); } @@ -375,5 +402,29 @@ fn test_compiled_output_no_unreplaced_markers() { "Compiled output should reference GitHub Releases for AWF" ); + // Verify MCPG references + assert!( + compiled.contains("ghcr.io/github/gh-aw-mcpg"), + "Compiled output should reference MCPG Docker image" + ); + assert!( + compiled.contains("host.docker.internal"), + "Compiled output should reference host.docker.internal for MCPG" + ); + assert!( + compiled.contains("--enable-host-access"), + "Compiled output should include --enable-host-access for AWF" + ); + + // Verify no legacy MCP firewall references + assert!( + !compiled.contains("mcp-firewall"), + "Compiled output should not reference legacy mcp-firewall" + ); + assert!( + !compiled.contains("mcp_firewall"), + "Compiled output should not reference legacy mcp_firewall" + ); + let _ = fs::remove_dir_all(&temp_dir); } From 5efe1b0709e985d21616b2c6f6959d644590512b Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 8 Mar 2026 23:17:30 +0000 Subject: [PATCH 05/19] docs: update AGENTS.md for MCPG migration - Update architecture file tree (remove mcp_firewall.rs, mcp_metadata.rs, mcp-metadata.json) - Replace {{ firewall_config }} marker docs with {{ mcpg_config }} - Add {{ mcpg_version }} and {{ mcpg_image }} marker documentation - Update {{ agency_params }} to remove MCP-related flags - Replace mcp-firewall CLI docs with mcp-http subcommand - Remove 'Built-in MCP Servers' section (no built-in MCPs exist) - Simplify MCP Configuration to custom servers only - Add host.docker.internal to allowed domains table - Replace entire MCP Firewall section with MCP Gateway (MCPG) docs - Update standalone target description for MCPG - Update front matter example to remove built-in MCP references - Add gh-aw-mcpg and gh-aw-firewall to References Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 279 ++++++++++++++++++++---------------------------------- 1 file changed, 102 insertions(+), 177 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d06f60..2f14e98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,7 @@ Alongside the correctly generated pipeline yaml, an agent file is generated from │ ├── execute.rs # Stage 2 safe output execution │ ├── fuzzy_schedule.rs # Fuzzy schedule parsing │ ├── logging.rs # File-based logging infrastructure -│ ├── mcp.rs # SafeOutputs MCP server -│ ├── mcp_firewall.rs # MCP Firewall server -│ ├── mcp_metadata.rs # Bundled MCP metadata +│ ├── mcp.rs # SafeOutputs MCP server (stdio + HTTP) │ ├── ndjson.rs # NDJSON parsing utilities │ ├── proxy.rs # Network proxy implementation │ ├── sanitize.rs # Input sanitization for safe outputs @@ -50,7 +48,6 @@ Alongside the correctly generated pipeline yaml, an agent file is generated from │ ├── base.yml # Base pipeline template for standalone │ ├── 1es-base.yml # Base pipeline template for 1ES target │ └── threat-analysis.md # Threat detection analysis prompt template -├── mcp-metadata.json # Bundled MCP tool definitions ├── examples/ # Example agent definitions ├── tests/ # Integration tests and fixtures ├── Cargo.toml # Rust dependencies @@ -126,18 +123,7 @@ checkout: # optional list of repository aliases for the agent to checkout and wo # env: # RESERVED: workflow-level environment variables (not yet implemented) # CUSTOM_VAR: "value" mcp-servers: - ado: true # built-in, enabled with defaults - bluebird: true - es-chat: true - msft-learn: true - icm: - allowed: # built-in with restricted functions - - create_incident - - get_incident - kusto: - allowed: - - query - my-custom-tool: # custom MCP server (has command field) + my-custom-tool: # custom MCP server (requires command field) command: "node" args: ["path/to/mcp-server.js"] allowed: @@ -286,7 +272,7 @@ The `target` field in the front matter determines the output format and executio Generates a self-contained Azure DevOps pipeline with: - Full 3-job pipeline: `PerformAgenticTask` → `AnalyzeSafeOutputs` → `ProcessSafeOutputs` - AWF (Agentic Workflow Firewall) L7 domain whitelisting via Squid proxy + Docker -- MCP firewall with tool-level filtering and custom MCP server support +- MCP Gateway (MCPG) for MCP routing with SafeOutputs HTTP backend - Setup/teardown job support - All safe output features (create-pull-request, create-work-item, etc.) @@ -390,13 +376,10 @@ Should be replaced with the human-readable name from the front matter (e.g., "Da Additional params provided to agency CLI. The compiler generates: - `--model ` - AI model from `engine` front matter field (default: claude-opus-4.5) -- `--disable-builtin-mcps` - Disables all built-in MCPs initially - `--no-ask-user` - Prevents interactive prompts - `--allow-tool ` - Explicitly allows specific tools (github, safeoutputs, write, shell commands like cat, date, echo, grep, head, ls, pwd, sort, tail, uniq, wc, yq) -- `--disable-mcp-server ` - Disables specific MCPs (all built-in MCPs are disabled by default and must be explicitly enabled via mcp-servers config) -- `--mcp ` - Enables MCPs specified in front matter -Only built-in MCPs are passed via params. Custom MCPs (with command field) are handled separately. +MCP servers are handled entirely by the MCP Gateway (MCPG) and are not passed as copilot CLI params. ## {{ pool }} @@ -513,9 +496,17 @@ resources: Should be replaced with the markdown body (agent instructions) extracted from the source markdown file, excluding the YAML front matter. This content provides the agent with its task description and guidelines. -## {{ firewall_config }} +## {{ mcpg_config }} + +Should be replaced with the MCP Gateway (MCPG) configuration JSON generated from the `mcp-servers:` front matter. This configuration defines the MCPG server entries and gateway settings. + +The generated JSON has two top-level sections: +- `mcpServers`: Maps server names to their configuration (type, command/url, tools, etc.) +- `gateway`: Gateway settings (port, domain, apiKey, payloadDir) -Should be replaced with the MCP firewall configuration JSON generated from the `mcp-servers:` front matter. This configuration defines which MCP servers to spawn and which tools are allowed for each upstream. +SafeOutputs is always included as an HTTP backend (`type: "http"`) pointing to `host.docker.internal`. Custom MCPs with explicit `command:` are included as stdio servers (`type: "stdio"`). MCPs without a command are skipped (there are no built-in MCPs in the copilot CLI). + +Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline at runtime before passing the config to MCPG. ## {{ allowed_domains }} @@ -605,6 +596,14 @@ https://github.com/github/gh-aw-firewall/releases/download/v{VERSION}/awf-linux- A `checksums.txt` file is also downloaded and verified via `sha256sum -c checksums.txt --ignore-missing` to ensure binary integrity. +## {{ mcpg_version }} + +Should be replaced with the pinned version of the MCP Gateway (defined as `MCPG_VERSION` constant in `src/compile/common.rs`). Used to tag the MCPG Docker image in the pipeline. + +## {{ mcpg_image }} + +Should be replaced with the MCPG Docker image name (defined as `MCPG_IMAGE` constant in `src/compile/common.rs`). Currently `ghcr.io/github/gh-aw-mcpg`. + ### 1ES-Specific Template Markers The following markers are specific to the 1ES target (`target: 1es`) and are not used in standalone pipelines: @@ -617,7 +616,7 @@ Should be replaced with the agent context root for 1ES Agency jobs. This determi ## {{ mcp_configuration }} -Should be replaced with the MCP server configuration for 1ES templates. For each enabled built-in MCP, generates service connection references: +Should be replaced with the MCP server configuration for 1ES templates. For each enabled MCP with a service connection, generates service connection references: ```yaml ado: @@ -626,7 +625,7 @@ kusto: serviceConnection: mcp-kusto-service-connection ``` -Custom MCP servers (with `command:` field) are not supported in 1ES target. Only built-in MCPs with corresponding service connections are supported. +Custom MCP servers (with `command:` field) are not supported in 1ES target. MCPs must have service connection configuration. ## {{ global_options }} @@ -650,7 +649,10 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `` - Path to the source markdown file - `` - Path to the pipeline YAML file to verify - Useful for CI checks to ensure pipelines are regenerated after source changes -- `mcp ` - Run as an MCP server for safe outputs +- `mcp ` - Run SafeOutputs as a stdio MCP server +- `mcp-http ` - Run SafeOutputs as an HTTP MCP server (for MCPG integration) + - `--port ` - Port to listen on (default: 8100) + - `--api-key ` - API key for authentication (auto-generated if not provided) - `execute` - Execute safe outputs from Stage 1 (Stage 2 of pipeline) - `--source, -s ` - Path to source markdown file - `--safe-output-dir ` - Directory containing safe output NDJSON (default: current directory) @@ -659,8 +661,6 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `--ado-project ` - Azure DevOps project name override - `proxy` - Start an HTTP proxy for network filtering - `--allow ` - Allowed hosts (supports wildcards, can be repeated) -- `mcp-firewall` - Start an MCP firewall server that proxies tool calls - - `--config, -c ` - Path to firewall configuration JSON file ## Safe Outputs Configuration @@ -883,34 +883,11 @@ cargo add ## MCP Configuration -The `mcp-servers:` field provides a unified way to configure both built-in and custom MCP (Model Context Protocol) servers. The compiler distinguishes between them by checking for the `command:` field—if present, it's a custom server; otherwise, it's a built-in. - -### Built-in MCP Servers - -Enable built-in servers with `true` or configure them with options: - -```yaml -mcp-servers: - ado: true # enabled with all default functions - ado-ext: true # Extended ADO functionality - asa: true # Azure Stream Analytics MCP - bluebird: true # Bluebird MCP - calculator: true # Calculator MCP - es-chat: true - icm: # enabled with restricted functions - allowed: - - create_incident - - get_incident - kusto: - allowed: - - query - msft-learn: true - stack: true # Stack MCP -``` +The `mcp-servers:` field configures MCP (Model Context Protocol) servers that are made available to the agent via the MCP Gateway (MCPG). All MCPs require explicit `command:` configuration — there are no built-in MCPs in the copilot CLI. ### Custom MCP Servers -Define custom servers by including a `command:` field: +Define MCP servers by including a `command:` field: ```yaml mcp-servers: @@ -924,27 +901,15 @@ mcp-servers: ### Configuration Properties -**For built-in MCPs:** -- `true` - Enable with all default functions -- `allowed:` - Array of function names to restrict available tools - -**For custom MCPs (requires `command:`):** - `command:` - The executable to run (e.g., `"node"`, `"python"`, `"dotnet"`) - `args:` - Array of command-line arguments passed to the command - `allowed:` - Array of function names agents are permitted to call (required for security) - `env:` - Optional environment variables for the MCP server process -### Example: Mixed Configuration +### Example Configuration ```yaml mcp-servers: - # Built-in servers - ado: true - ado-ext: true - es-chat: true - icm: - allowed: [create_incident, get_incident] - # Custom Python MCP server data-processor: command: "python" @@ -970,7 +935,7 @@ mcp-servers: 2. **Command Validation**: The compiler validates that commands are from a trusted set 3. **Argument Sanitization**: Arguments are validated to prevent injection attacks 4. **Environment Isolation**: MCP servers run in the same isolated sandbox as the pipeline -5. **Built-in Trust**: Built-in MCPs are pre-vetted; custom MCPs require explicit `allowed:` list +5. **MCPG Gateway**: All MCP traffic flows through the MCP Gateway which enforces tool-level filtering ## Network Isolation (AWF) @@ -1016,6 +981,7 @@ The following domains are always allowed (defined in `allowed_hosts.rs`): | `dc.services.visualstudio.com` | Visual Studio telemetry | | `rt.services.visualstudio.com` | Visual Studio runtime telemetry | | `config.edge.skype.com` | Agency configuration | +| `host.docker.internal` | MCP Gateway (MCPG) on host | ### Adding Additional Hosts @@ -1047,145 +1013,104 @@ When not configured: - ADO access tokens are omitted from the copilot invocation - The agent cannot authenticate to ADO APIs -## MCP Firewall +## MCP Gateway (MCPG) -The MCP Firewall is a security layer that acts as a filtering proxy between agents and their configured MCP servers. It provides policy-based access control and audit logging for all tool calls. - -### Purpose - -When agents are configured with multiple MCPs (e.g., `ado`, `kusto`, `icm`), the firewall: - -1. **Loads tool definitions** from pre-generated metadata (`mcp-metadata.json`) -2. **Enforces allow-lists** - only exposes tools explicitly permitted in the config -3. **Namespaces tools** - tools appear as `upstream:tool_name` (e.g., `icm:create_incident`) -4. **Spawns upstream MCPs lazily** as child processes when tools are actually called -5. **Routes tool calls** to the appropriate upstream server -6. **Logs all attempts** for security auditing +The MCP Gateway ([gh-aw-mcpg](https://github.com/github/gh-aw-mcpg)) is the upstream MCP routing layer that connects agents to their configured MCP servers. It replaces the previous custom MCP firewall with the standard gh-aw gateway implementation. ### Architecture ``` -┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ │ │ │ │ ado MCP │ -│ Agent │────▶│ MCP Firewall │────▶│ (agency mcp ado)│ -│ (Agency) │ │ │ └─────────────────┘ -│ │ │ - Policy check │ ┌─────────────────┐ -└─────────────┘ │ - Tool routing │────▶│ icm MCP │ - │ - Audit logging │ │ (agency mcp icm)│ - └──────────────────┘ └─────────────────┘ - ┌─────────────────┐ - ────▶│ custom MCP │ - │ (node server.js)│ - └─────────────────┘ + Host +┌─────────────────────────────────────────────────┐ +│ │ +│ ┌──────────────┐ ┌──────────────────────┐ │ +│ │ SafeOutputs │ │ MCPG Gateway │ │ +│ │ HTTP Server │◀────│ (Docker, --network │ │ +│ │ (ado-aw │ │ host, port 80) │ │ +│ │ mcp-http) │ │ │ │ +│ │ port 8100 │ │ Routes tool calls │ │ +│ └──────────────┘ │ to upstreams │ │ +│ └──────────┬───────────┘ │ +│ │ │ +│ ┌─────────────────┐ │ │ +│ │ Custom MCP │◀────┘ │ +│ │ (stdio server) │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────┘ + │ + host.docker.internal:80 + │ +┌─────────────────────────────────────────────────┐ +│ AWF Container │ +│ │ +│ ┌──────────┐ │ +│ │ Copilot │──── HTTP ──── MCPG (via host) │ +│ │ Agent │ │ +│ └──────────┘ │ +└─────────────────────────────────────────────────┘ ``` -### Configuration File Format +### How It Works + +1. **SafeOutputs HTTP server** starts on the host (port 8100) via `ado-aw mcp-http` +2. **MCPG container** starts on the host network (`docker run --network host`) +3. **MCPG config** (generated by the compiler) defines: + - SafeOutputs as an HTTP backend (`type: "http"`, URL points to localhost:8100) + - Custom MCPs as stdio servers (`type: "stdio"`, spawned by MCPG) + - Gateway settings (port 80, API key, payload directory) +4. **Agent inside AWF** connects to MCPG via `http://host.docker.internal:80/mcp` +5. MCPG routes tool calls to the appropriate upstream (SafeOutputs or custom MCPs) +6. After the agent completes, MCPG and SafeOutputs are stopped + +### MCPG Configuration Format -The firewall reads a JSON configuration file at runtime: +The compiler generates MCPG configuration JSON from the `mcp-servers:` front matter: ```json { - "upstreams": { - "ado": { - "command": "agency", - "args": ["mcp", "ado"], - "env": {}, - "allowed": ["*"] - }, - "icm": { - "command": "agency", - "args": ["mcp", "icm"], - "env": {}, - "allowed": ["create_incident", "get_incident"] - }, - "kusto": { - "command": "agency", - "args": ["mcp", "kusto"], - "env": {}, - "allowed": ["query"] + "mcpServers": { + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:8100/mcp", + "headers": { + "Authorization": "Bearer " + } }, "custom-tool": { + "type": "stdio", "command": "node", "args": ["server.js"], - "env": { "NODE_ENV": "production" }, - "allowed": ["process_data", "get_status"], - "spawn_timeout_secs": 60 + "tools": ["process_data", "get_status"] } + }, + "gateway": { + "port": 80, + "domain": "host.docker.internal", + "apiKey": "", + "payloadDir": "/tmp/gh-aw/mcp-payloads" } } ``` -### Configuration Properties (Firewall) - -Each upstream configuration supports: - -| Property | Required | Default | Description | -|----------|----------|---------|-------------| -| `command` | Yes | - | The executable to spawn | -| `args` | No | `[]` | Arguments passed to the command | -| `env` | No | `{}` | Environment variables for the process | -| `allowed` | Yes | - | Tool names allowed (supports `"*"` and prefix wildcards) | -| `spawn_timeout_secs` | No | `30` | Timeout in seconds for spawning and initializing the MCP server | - -### Allow-list Patterns - -The `allowed` field supports several patterns: - -| Pattern | Description | Example | -|---------|-------------|---------| -| `"*"` | Allow all tools from this upstream | `["*"]` | -| `"exact_name"` | Allow only this specific tool | `["query", "execute"]` | -| `"prefix_*"` | Allow tools starting with prefix | `["get_*", "list_*"]` | - -### Tool Namespacing - -All tools exposed by the firewall are namespaced with their upstream name: - -- `ado:create-work-item` - from the `ado` upstream -- `icm:create_incident` - from the `icm` upstream -- `kusto:query` - from the `kusto` upstream - -This prevents tool name collisions and makes it clear which upstream handles each call. - -### CLI Usage - -```bash -# Start the MCP firewall server -ado-aw mcp-firewall --config /path/to/config.json -``` +Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline before passing the config to MCPG. ### Pipeline Integration -The firewall is automatically configured in generated pipelines: - -1. **Config Generation**: The compiler generates `mcp-firewall-config.json` from the agent's `mcp-servers:` front matter -2. **MCP Registration**: The firewall is registered in the agency MCP config as `mcp-firewall` -3. **Runtime Launch**: When agency starts, it launches the firewall which spawns upstream MCPs - -The firewall config is written to `$(Agent.TempDirectory)/staging/mcp-firewall-config.json` in its own pipeline step, making it easy to inspect and debug. - -### Audit Logging - -All tool call attempts are logged to the centralized log file at `$HOME/.ado-aw/logs/YYYY-MM-DD.log`: - -``` -[2026-01-29T10:15:32Z] [INFO] [firewall] ALLOWED icm:create_incident (args: {"title": "...", "severity": 3}) -[2026-01-29T10:15:45Z] [INFO] [firewall] BLOCKED icm:delete_incident (not in allowlist) -[2026-01-29T10:16:01Z] [INFO] [firewall] ALLOWED kusto:query (args: {"cluster": "...", "query": "..."}) -``` - -This provides a complete audit trail of agent actions for security review. +The MCPG is automatically configured in generated standalone pipelines: -### Error Handling +1. **Config Generation**: The compiler generates `mcpg-config.json` from the agent's `mcp-servers:` front matter +2. **SafeOutputs Start**: `ado-aw mcp-http` starts as a background process on the host +3. **MCPG Start**: The MCPG Docker container starts on the host network with config via stdin +4. **Agent Execution**: AWF runs the agent with `--enable-host-access`, copilot connects to MCPG via HTTP +5. **Cleanup**: Both MCPG and SafeOutputs are stopped after the agent completes (condition: always) -- **Upstream spawn failure**: If an upstream fails to start, the firewall continues with remaining upstreams (partial functionality) -- **Tool not found**: Returns an MCP error if the requested tool doesn't exist -- **Policy violation**: Returns an MCP error if the tool exists but isn't in the allow-list -- **Upstream error**: Propagates errors from upstream MCPs back to the agent +The MCPG config is written to `$(Agent.TempDirectory)/staging/mcpg-config.json` in its own pipeline step, making it easy to inspect and debug. ## References - [GitHub Agentic Workflows](https://github.com/githubnext/gh-aw) - Inspiration for this project +- [MCP Gateway (gh-aw-mcpg)](https://github.com/github/gh-aw-mcpg) - MCP routing gateway +- [AWF (gh-aw-firewall)](https://github.com/github/gh-aw-firewall) - Network isolation firewall - [Azure DevOps YAML Schema](https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema) - [OneBranch Documentation](https://aka.ms/onebranchdocs) - [Clap Documentation](https://docs.rs/clap/latest/clap/) From fac2f2d4b85f86fa011b8dd8da2b4bfadeffbcad Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 8 Mar 2026 23:20:09 +0000 Subject: [PATCH 06/19] fix: address review feedback on MCPG integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always call generate_mcpg_config() regardless of mcp_servers being empty — safeoutputs must always be present in the MCPG config - Pre-initialize SafeOutputs outside the StreamableHttpService factory closure to avoid block_on() panic on a Tokio worker thread - Add failure guards to readiness wait loops in base.yml: both SafeOutputs and MCPG now fail the pipeline step explicitly if they don't become ready within 30 seconds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/standalone.rs | 13 +++++-------- src/mcp.rs | 17 ++++++----------- templates/base.yml | 12 ++++++++++++ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 44a982c..2fbc5dc 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -160,14 +160,11 @@ impl Compiler for StandaloneCompiler { replace_with_indent(&yaml, placeholder, replacement) }); - // Generate MCPG config JSON - let mcpg_config_json = if !front_matter.mcp_servers.is_empty() { - let config = generate_mcpg_config(front_matter); - serde_json::to_string_pretty(&config) - .unwrap_or_else(|_| r#"{"mcpServers":{}}"#.to_string()) - } else { - r#"{"mcpServers":{}}"#.to_string() - }; + // Always generate MCPG config — safeoutputs is always required regardless + // of whether additional mcp-servers are configured in front matter. + let config = generate_mcpg_config(front_matter); + let mcpg_config_json = serde_json::to_string_pretty(&config) + .unwrap_or_else(|_| r#"{"mcpServers":{}}"#.to_string()); let pipeline_yaml = replace_with_indent( &pipeline_yaml, diff --git a/src/mcp.rs b/src/mcp.rs index d4e3965..8acad05 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -475,18 +475,13 @@ pub async fn run_http( let session_manager = Arc::new(LocalSessionManager::default()); - let bounding_clone = bounding.clone(); - let output_clone = output.clone(); + // Pre-initialize SafeOutputs once and share via clone. + // The factory closure runs on a Tokio worker thread, so we cannot + // use block_on() inside it — that would panic with "Cannot start + // a runtime from within a runtime". + let safe_outputs_template = SafeOutputs::new(&bounding, &output).await?; let mcp_service = StreamableHttpService::new( - move || { - let bounding = bounding_clone.clone(); - let output = output_clone.clone(); - let rt = tokio::runtime::Handle::current(); - let safe_outputs = rt.block_on(async { - SafeOutputs::new(&bounding, &output).await - }).map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(safe_outputs) - }, + move || Ok(safe_outputs_template.clone()), session_manager, config, ); diff --git a/templates/base.yml b/templates/base.yml index d4a021f..d26f47e 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -208,13 +208,19 @@ jobs: echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready + READY=false for i in $(seq 1 30); do if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" + READY=true break fi sleep 1 done + if [ "$READY" != "true" ]; then + echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" + exit 1 + fi displayName: "Start SafeOutputs HTTP server" # Start MCP Gateway (MCPG) on host @@ -243,13 +249,19 @@ jobs: echo "MCPG started (PID: $MCPG_PID)" # Wait for MCPG to be ready + READY=false for i in $(seq 1 30); do if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then echo "MCPG is ready" + READY=true break fi sleep 1 done + if [ "$READY" != "true" ]; then + echo "##vso[task.complete result=Failed]MCPG did not become ready within 30s" + exit 1 + fi displayName: "Start MCP Gateway (MCPG)" # Network isolation via AWF (Agentic Workflow Firewall) From 0c3ed0b90d154f611f84b7cf4cbc6e7168ff6863 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 8 Mar 2026 23:39:21 +0000 Subject: [PATCH 07/19] fix: harden security per PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move host.docker.internal from CORE_ALLOWED_HOSTS to standalone compiler's generate_allowed_domains — only pipelines using MCPG need host access from the AWF container - Replace weak time-based API key fallback with /dev/urandom (32 bytes); time-based value retained only as last-resort if urandom is unavailable - Remove SAFE_OUTPUTS_API_KEY println to avoid leaking the secret to log files; the pipeline already knows the key it generated - Add security comment explaining why Docker socket mount is required for MCPG (spawns stdio MCP servers as sibling containers) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/allowed_hosts.rs | 5 +++-- src/mcp.rs | 31 ++++++++++++++++++++++--------- templates/base.yml | 5 ++++- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/allowed_hosts.rs b/src/allowed_hosts.rs index 59e1781..c223231 100644 --- a/src/allowed_hosts.rs +++ b/src/allowed_hosts.rs @@ -53,9 +53,10 @@ pub static CORE_ALLOWED_HOSTS: &[&str] = &[ "rt.services.visualstudio.com", // ===== Agency / Copilot configuration ===== "config.edge.skype.com", - // ===== MCP Gateway (host-side MCPG accessible from AWF container) ===== - "host.docker.internal", // Note: 168.63.129.16 (Azure DNS) is handled separately as it's an IP + // Note: host.docker.internal is NOT in CORE — it's added conditionally + // by the standalone compiler when MCPG is configured, to avoid granting + // host access to pipelines that don't need it. ]; /// Hosts required by specific MCP servers. diff --git a/src/mcp.rs b/src/mcp.rs index 8acad05..30a19fd 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -454,16 +454,29 @@ pub async fn run_http( let bounding = bounding_directory.to_string(); let output = output_directory.to_string(); - // Generate or use provided API key + // Generate or use provided API key. + // In production the pipeline always passes --api-key with a cryptographically + // random value; this fallback covers dev/test invocations. let api_key = api_key .map(|k| k.to_string()) .unwrap_or_else(|| { - use std::time::{SystemTime, UNIX_EPOCH}; - let seed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("{:x}", seed) + let mut buf = [0u8; 32]; + std::fs::File::open("/dev/urandom") + .and_then(|mut f| { + use std::io::Read; + f.read_exact(&mut buf) + }) + .unwrap_or_else(|_| { + // Last-resort fallback if /dev/urandom is unavailable + use std::time::{SystemTime, UNIX_EPOCH}; + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + buf[..16].copy_from_slice(&seed.to_le_bytes()); + buf[16..].copy_from_slice(&seed.wrapping_mul(0x517cc1b727220a95).to_le_bytes()); + }); + buf.iter().map(|b| format!("{:02x}", b)).collect() }); info!("Starting SafeOutputs HTTP server on port {}", port); @@ -524,9 +537,9 @@ pub async fn run_http( let listener = tokio::net::TcpListener::bind(addr).await?; info!("SafeOutputs HTTP server listening on {}", addr); - // Print connection info for pipeline capture + // Print port for pipeline capture (key is already known by the caller) println!("SAFE_OUTPUTS_PORT={}", port); - println!("SAFE_OUTPUTS_API_KEY={}", api_key); + log::debug!("SafeOutputs API key configured (not printed for security)"); axum::serve(listener, app) .with_graceful_shutdown(async { diff --git a/templates/base.yml b/templates/base.yml index d26f47e..3aa23b8 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -237,7 +237,10 @@ jobs: echo "Starting MCPG with config:" echo "$MCPG_CONFIG" | python3 -m json.tool - # Start MCPG Docker container on host network + # Start MCPG Docker container on host network. + # The Docker socket mount is required because MCPG spawns stdio-based MCP + # servers as sibling containers. This grants significant host access — acceptable + # here because the pipeline agent is already trusted and network-isolated by AWF. echo "$MCPG_CONFIG" | docker run -i --rm \ --name mcpg \ --network host \ From 0ffbf7c8930f6bf7d6daa3d9e176ec9fded6f87e Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 8 Mar 2026 23:39:29 +0000 Subject: [PATCH 08/19] refactor: improve code quality per PR review feedback - Extract MCPG_PORT constant alongside MCPG_VERSION/MCPG_IMAGE to avoid hardcoded port 80 in generated config - Propagate MCPG config serialization error with ? instead of silently falling back to broken JSON missing the gateway section - Omit empty args array from MCPG config for cleaner output (consistent with existing env/tools handling) - Add warning in 1ES compiler when non-custom MCPs fall back to convention-based service connection names that may not exist Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 3 +++ src/compile/onees.rs | 15 +++++++++++++-- src/compile/standalone.rs | 22 ++++++++++++++++------ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index 1b2184d..914e98a 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -441,6 +441,9 @@ pub const AWF_VERSION: &str = "0.23.1"; pub const MCPG_VERSION: &str = "0.1.9"; pub const MCPG_IMAGE: &str = "ghcr.io/github/gh-aw-mcpg"; +/// Default port MCPG listens on inside the container (host network mode). +pub const MCPG_PORT: u16 = 80; + /// Generate source path for the execute command. /// /// Returns a path using `{{ workspace }}` as the base, which gets resolved diff --git a/src/compile/onees.rs b/src/compile/onees.rs index 83c8c87..bcbd867 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -203,10 +203,21 @@ fn generate_mcp_configuration(mcps: &HashMap) -> String { return None; } - // Use explicit service connection or generate default + // Use explicit service connection or generate default. + // Warn when falling back to the naming convention — the generated + // service connection reference may not exist in the ADO project. let service_connection = opts .and_then(|o| o.service_connection.clone()) - .unwrap_or_else(|| format!("mcp-{}-service-connection", name)); + .unwrap_or_else(|| { + let default = format!("mcp-{}-service-connection", name); + log::warn!( + "MCP '{}' has no explicit service connection in 1ES target — \ + assuming '{}' exists", + name, + default, + ); + default + }); Some((name.clone(), service_connection)) }) diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 2fbc5dc..e098d54 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -14,9 +14,9 @@ use std::path::Path; use super::Compiler; use super::common::{ - self, AWF_VERSION, DEFAULT_POOL, MCPG_IMAGE, MCPG_VERSION, compute_effective_workspace, - generate_copilot_params, generate_cancel_previous_builds, generate_checkout_self, - generate_checkout_steps, generate_ci_trigger, generate_pipeline_path, + self, AWF_VERSION, DEFAULT_POOL, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, + compute_effective_workspace, generate_copilot_params, generate_cancel_previous_builds, + generate_checkout_self, generate_checkout_steps, generate_ci_trigger, generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path, generate_working_directory, replace_with_indent, sanitize_filename, }; @@ -164,7 +164,7 @@ impl Compiler for StandaloneCompiler { // of whether additional mcp-servers are configured in front matter. let config = generate_mcpg_config(front_matter); let mcpg_config_json = serde_json::to_string_pretty(&config) - .unwrap_or_else(|_| r#"{"mcpServers":{}}"#.to_string()); + .context("Failed to serialize MCPG config")?; let pipeline_yaml = replace_with_indent( &pipeline_yaml, @@ -214,6 +214,11 @@ fn generate_allowed_domains(front_matter: &FrontMatter) -> String { hosts.insert((*host).to_string()); } + // Add host.docker.internal — required for the AWF container to reach + // MCPG and SafeOutputs on the host. Only added for standalone pipelines + // that always use MCPG. + hosts.insert("host.docker.internal".to_string()); + // Add MCP-specific hosts for mcp in &enabled_mcps { for host in mcp_required_hosts(mcp) { @@ -414,6 +419,11 @@ pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { if let Some(opts) = options { if let Some(command) = &opts.command { // Custom MCP with explicit command → stdio server + let args = if opts.args.is_empty() { + None + } else { + Some(opts.args.clone()) + }; let env = if opts.env.is_empty() { None } else { @@ -429,7 +439,7 @@ pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { McpgServerConfig { server_type: "stdio".to_string(), command: Some(command.clone()), - args: Some(opts.args.clone()), + args, url: None, headers: None, env, @@ -453,7 +463,7 @@ pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { McpgConfig { mcp_servers, gateway: McpgGatewayConfig { - port: 80, + port: MCPG_PORT, domain: "host.docker.internal".to_string(), api_key: "${MCP_GATEWAY_API_KEY}".to_string(), payload_dir: "/tmp/gh-aw/mcp-payloads".to_string(), From a86e1cce995676dc82a308f79204019e4d5ae7ad Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 8 Mar 2026 23:48:02 +0000 Subject: [PATCH 09/19] fix: address round 3 PR review feedback - Guard reserved 'safeoutputs' name in generate_mcpg_config to prevent user-defined MCPs from overwriting the safe outputs HTTP backend - Log MCPG config template before API key substitution to avoid leaking MCP_GATEWAY_API_KEY to pipeline logs (ADO secret masking only applies in subsequent steps, not the current step's output) - Clarify allowed_hosts.rs comment: host.docker.internal is always added for standalone pipelines (not conditionally) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/allowed_hosts.rs | 6 +++--- src/compile/standalone.rs | 8 ++++++++ templates/base.yml | 7 +++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/allowed_hosts.rs b/src/allowed_hosts.rs index c223231..d5884eb 100644 --- a/src/allowed_hosts.rs +++ b/src/allowed_hosts.rs @@ -54,9 +54,9 @@ pub static CORE_ALLOWED_HOSTS: &[&str] = &[ // ===== Agency / Copilot configuration ===== "config.edge.skype.com", // Note: 168.63.129.16 (Azure DNS) is handled separately as it's an IP - // Note: host.docker.internal is NOT in CORE — it's added conditionally - // by the standalone compiler when MCPG is configured, to avoid granting - // host access to pipelines that don't need it. + // Note: host.docker.internal is NOT in CORE — it's always added by the + // standalone compiler in generate_allowed_domains (standalone always uses + // MCPG, which needs host access from the AWF container). ]; /// Hosts required by specific MCP servers. diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index e098d54..2a5194c 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -407,6 +407,14 @@ pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { ); for (name, config) in &front_matter.mcp_servers { + // Prevent user-defined MCPs from overwriting the reserved safeoutputs backend + if name == "safeoutputs" { + log::warn!( + "MCP name 'safeoutputs' is reserved for the safe outputs HTTP backend — skipping" + ); + continue; + } + let (is_enabled, options) = match config { McpConfig::Enabled(enabled) => (*enabled, None), McpConfig::WithOptions(opts) => (true, Some(opts)), diff --git a/templates/base.yml b/templates/base.yml index 3aa23b8..4b8929b 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -234,8 +234,11 @@ jobs: | sed "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ | sed "s|\${MCP_GATEWAY_API_KEY}|$MCP_GATEWAY_API_KEY|g") - echo "Starting MCPG with config:" - echo "$MCPG_CONFIG" | python3 -m json.tool + # Log the template config (before API key substitution) for debugging. + # Logging after substitution would leak MCP_GATEWAY_API_KEY since it's a + # local bash variable — ADO's secret masking only applies in subsequent steps. + echo "Starting MCPG with config template:" + cat /tmp/awf-tools/staging/mcpg-config.json | python3 -m json.tool # Start MCPG Docker container on host network. # The Docker socket mount is required because MCPG spawns stdio-based MCP From 3a43cdd85473dbfa84b898bbf20c7edd7370f964 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 10 Mar 2026 22:04:04 +0000 Subject: [PATCH 10/19] fix: harden SafeOutputs HTTP server security - Use subtle::ConstantTimeEq for API key comparison to prevent timing side-channel attacks from a compromised AWF container - Fail loudly (panic) when /dev/urandom is unavailable instead of silently generating a weak time-based key - Add doc comment on SafeOutputs Clone confirming concurrent safety: only contains immutable PathBuf fields, file I/O opens fresh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + Cargo.toml | 1 + src/mcp.rs | 37 +++++++++++++++++++++---------------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30d5fd3..5ccc8f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "subtle", "tempfile", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index be2d083..9b707a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ env_logger = "0.11" regex-lite = "0.1" inquire = { version = "0.9.2", features = ["editor"] } axum = { version = "0.8.8", features = ["tokio"] } +subtle = "2.6.1" diff --git a/src/mcp.rs b/src/mcp.rs index 30a19fd..20937be 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -52,6 +52,9 @@ fn generate_short_id() -> String { // SafeOutputs MCP Server // ============================================================================ +/// SafeOutputs is safe to clone for concurrent use: it only contains immutable +/// `PathBuf` fields and a `ToolRouter`. File I/O (NDJSON append) opens files +/// fresh on each call, so no shared mutable state exists between clones. #[derive(Clone, Debug)] pub struct SafeOutputs { bounding_directory: PathBuf, @@ -461,22 +464,21 @@ pub async fn run_http( .map(|k| k.to_string()) .unwrap_or_else(|| { let mut buf = [0u8; 32]; - std::fs::File::open("/dev/urandom") + match std::fs::File::open("/dev/urandom") .and_then(|mut f| { use std::io::Read; f.read_exact(&mut buf) - }) - .unwrap_or_else(|_| { - // Last-resort fallback if /dev/urandom is unavailable - use std::time::{SystemTime, UNIX_EPOCH}; - let seed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - buf[..16].copy_from_slice(&seed.to_le_bytes()); - buf[16..].copy_from_slice(&seed.wrapping_mul(0x517cc1b727220a95).to_le_bytes()); - }); - buf.iter().map(|b| format!("{:02x}", b)).collect() + }) { + Ok(()) => buf.iter().map(|b| format!("{:02x}", b)).collect(), + Err(e) => { + // Fail loudly rather than generating a weak key + panic!( + "Cannot generate secure API key: /dev/urandom unavailable ({}). \ + Pass --api-key explicitly.", + e + ); + } + } }); info!("Starting SafeOutputs HTTP server on port {}", port); @@ -499,7 +501,8 @@ pub async fn run_http( config, ); - // Wrap with API key auth middleware + // Wrap with API key auth middleware (constant-time comparison to + // prevent timing side-channels from a compromised AWF container). let expected_key = api_key.clone(); let app = Router::new() .route("/health", axum::routing::get(|| async { "ok" })) @@ -517,10 +520,12 @@ pub async fn run_http( return next.run(req).await; } - // Check Bearer token + // Check Bearer token with constant-time comparison if let Some(auth) = req.headers().get("authorization") { if let Ok(auth_str) = auth.to_str() { - if auth_str == format!("Bearer {}", expected) { + let expected_header = format!("Bearer {}", expected); + use subtle::ConstantTimeEq; + if auth_str.as_bytes().ct_eq(expected_header.as_bytes()).into() { return next.run(req).await; } } From e5769bf33084dd9dcb76af4569380544f126b391 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 10 Mar 2026 22:04:46 +0000 Subject: [PATCH 11/19] fix: improve MCPG reliability and diagnostics - Pre-cleanup stale mcpg container before docker run to prevent retry failures when a previous run was interrupted (OOM/SIGKILL leaves container behind despite --rm) - Differentiate duplicate warning messages for MCPs without commands: options-but-no-command vs boolean-enabled (helps users understand migration path from removed built-in MCPs) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/standalone.rs | 7 +++++-- templates/base.yml | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 2a5194c..fd489fe 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -456,13 +456,16 @@ pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { ); } else { log::warn!( - "MCP '{}' has no command — skipping (no built-in MCPs available)", + "MCP '{}' has options but no command — skipping. \ + All MCPs now require an explicit command: field.", name ); } } else { log::warn!( - "MCP '{}' has no command — skipping (no built-in MCPs available)", + "MCP '{}' specified as boolean true — skipping. \ + Boolean-enabled MCPs required built-in MCPs which no longer exist. \ + Use the object form with a command: field instead.", name ); } diff --git a/templates/base.yml b/templates/base.yml index 4b8929b..3bfc8d0 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -240,6 +240,10 @@ jobs: echo "Starting MCPG with config template:" cat /tmp/awf-tools/staging/mcpg-config.json | python3 -m json.tool + # Remove any leftover container from a previous interrupted run + # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) + docker rm -f mcpg 2>/dev/null || true + # Start MCPG Docker container on host network. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable From 0c2434301821c7a60b51210cc2619a6e1da11ee3 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 10 Mar 2026 22:20:03 +0000 Subject: [PATCH 12/19] fix: improve SafeOutputs HTTP server robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace panic! with anyhow bail via .context() for /dev/urandom failure — panics in async contexts crash the Tokio task instead of propagating cleanly through the Result chain - Bind to 127.0.0.1 instead of 0.0.0.0 — MCPG runs with --network host so localhost is sufficient, reducing exposure on shared agents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mcp.rs | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/mcp.rs b/src/mcp.rs index 20937be..3eb993c 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use log::{debug, error, info, warn}; use rmcp::{ ErrorData as McpError, ServerHandler, ServiceExt, handler::server::tool::ToolRouter, @@ -460,26 +460,22 @@ pub async fn run_http( // Generate or use provided API key. // In production the pipeline always passes --api-key with a cryptographically // random value; this fallback covers dev/test invocations. - let api_key = api_key - .map(|k| k.to_string()) - .unwrap_or_else(|| { + let api_key = match api_key { + Some(k) => k.to_string(), + None => { let mut buf = [0u8; 32]; - match std::fs::File::open("/dev/urandom") + std::fs::File::open("/dev/urandom") .and_then(|mut f| { use std::io::Read; f.read_exact(&mut buf) - }) { - Ok(()) => buf.iter().map(|b| format!("{:02x}", b)).collect(), - Err(e) => { - // Fail loudly rather than generating a weak key - panic!( - "Cannot generate secure API key: /dev/urandom unavailable ({}). \ - Pass --api-key explicitly.", - e - ); - } - } - }); + }) + .context( + "Cannot generate secure API key: /dev/urandom unavailable. \ + Pass --api-key explicitly.", + )?; + buf.iter().map(|b| format!("{:02x}", b)).collect() + } + }; info!("Starting SafeOutputs HTTP server on port {}", port); @@ -538,7 +534,7 @@ pub async fn run_http( } })); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); let listener = tokio::net::TcpListener::bind(addr).await?; info!("SafeOutputs HTTP server listening on {}", addr); From 461715e8f592e2d3518f3bd192757508a5e41d68 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 10 Mar 2026 22:20:24 +0000 Subject: [PATCH 13/19] fix: correct MCPG networking for Linux host mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SafeOutputs URL in MCPG config changed from host.docker.internal to localhost — MCPG runs with --network host on Linux where host.docker.internal is not auto-injected in host network mode - Gateway domain remains host.docker.internal (used by AWF container which runs in bridge network mode with --enable-host-access) - Add docker rm -f pre-cleanup before MCPG container start to handle interrupted retry scenarios - Differentiate MCP warning messages for options-without-command vs boolean-enabled formats Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/standalone.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index fd489fe..624856b 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -389,14 +389,16 @@ pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { let mut mcp_servers = HashMap::new(); // SafeOutputs is always included as an HTTP backend. - // The actual URL/key are replaced at runtime by the pipeline template. + // MCPG runs with --network host, so it reaches SafeOutputs via localhost + // (not host.docker.internal, which requires Docker DNS and isn't available + // in host network mode on Linux). mcp_servers.insert( "safeoutputs".to_string(), McpgServerConfig { server_type: "http".to_string(), command: None, args: None, - url: Some("http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp".to_string()), + url: Some("http://localhost:${SAFE_OUTPUTS_PORT}/mcp".to_string()), headers: Some(HashMap::from([( "Authorization".to_string(), "Bearer ${SAFE_OUTPUTS_API_KEY}".to_string(), @@ -592,7 +594,7 @@ mod tests { let config = generate_mcpg_config(&fm); let so = config.mcp_servers.get("safeoutputs").unwrap(); assert_eq!(so.server_type, "http"); - assert!(so.url.as_ref().unwrap().contains("host.docker.internal")); + assert!(so.url.as_ref().unwrap().contains("localhost")); } #[test] From 7cb1c25b96c98a69263febdffe52ae9034a79df5 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 10 Mar 2026 22:50:58 +0000 Subject: [PATCH 14/19] fix: add MCPG client auth and generate API key early MCPG enforces authentication on incoming client requests. The agent's mcp-config.json now includes an Authorization header with the gateway API key. To support this, the MCP_GATEWAY_API_KEY is generated in the 'Prepare MCPG config' step (as an ADO secret variable) instead of the 'Start MCP Gateway' step, making it available when writing both the MCPG server config and the agent's client config. Also updates MCPG startup step to reference the ADO variable via $(MCP_GATEWAY_API_KEY) instead of the shell-local variable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- templates/base.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/templates/base.yml b/templates/base.yml index 3bfc8d0..ba3851e 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -79,6 +79,11 @@ jobs: - bash: | mkdir -p "$(Agent.TempDirectory)/staging" + # Generate MCPG API key early so it's available as an ADO secret variable + # for both the MCPG config and the agent's mcp-config.json + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "##vso[task.setvariable variable=MCP_GATEWAY_API_KEY;issecret=true]$MCP_GATEWAY_API_KEY" + # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' {{ mcpg_config }} @@ -113,14 +118,18 @@ jobs: # Copy MCPG config to /tmp cp "$(Agent.TempDirectory)/staging/mcpg-config.json" /tmp/awf-tools/staging/mcpg-config.json - # Generate MCP config for copilot CLI pointing to MCPG gateway on host - # The agent inside AWF reaches MCPG via host.docker.internal + # Generate MCP config for copilot CLI pointing to MCPG gateway on host. + # The agent inside AWF reaches MCPG via host.docker.internal. + # MCPG enforces client auth via the gateway API key. cat > /tmp/awf-tools/mcp-config.json << EOF { "mcpServers": { "mcpg": { "type": "http", - "url": "http://host.docker.internal:80/mcp" + "url": "http://host.docker.internal:80/mcp", + "headers": { + "Authorization": "Bearer $(MCP_GATEWAY_API_KEY)" + } } } } @@ -225,18 +234,13 @@ jobs: # Start MCP Gateway (MCPG) on host - bash: | - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "##vso[task.setvariable variable=MCP_GATEWAY_API_KEY;issecret=true]$MCP_GATEWAY_API_KEY" - # Substitute runtime values into MCPG config MCPG_CONFIG=$(cat /tmp/awf-tools/staging/mcpg-config.json \ | sed "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ | sed "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ - | sed "s|\${MCP_GATEWAY_API_KEY}|$MCP_GATEWAY_API_KEY|g") + | sed "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g") # Log the template config (before API key substitution) for debugging. - # Logging after substitution would leak MCP_GATEWAY_API_KEY since it's a - # local bash variable — ADO's secret masking only applies in subsequent steps. echo "Starting MCPG with config template:" cat /tmp/awf-tools/staging/mcpg-config.json | python3 -m json.tool @@ -252,7 +256,7 @@ jobs: --name mcpg \ --network host \ -v /var/run/docker.sock:/var/run/docker.sock \ - -e MCP_GATEWAY_API_KEY="$MCP_GATEWAY_API_KEY" \ + -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ {{ mcpg_image }}:v{{ mcpg_version }} & MCPG_PID=$! echo "##vso[task.setvariable variable=MCPG_PID]$MCPG_PID" From f5da07973d322fc97d5b8bac3faf46f2a8c34471 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 10 Mar 2026 22:52:59 +0000 Subject: [PATCH 15/19] fix: minor code quality improvements from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Response::builder().unwrap() with IntoResponse tuple for the 401 response in auth middleware (avoids unwrap on user path) - Case-insensitive safeoutputs name reservation check to prevent collision via 'SafeOutputs' or 'SAFEOUTPUTS' variants - Fix typo: 'required' → 'require' in user-visible warning message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/standalone.rs | 4 ++-- src/mcp.rs | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 624856b..5fde44c 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -410,7 +410,7 @@ pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { for (name, config) in &front_matter.mcp_servers { // Prevent user-defined MCPs from overwriting the reserved safeoutputs backend - if name == "safeoutputs" { + if name.eq_ignore_ascii_case("safeoutputs") { log::warn!( "MCP name 'safeoutputs' is reserved for the safe outputs HTTP backend — skipping" ); @@ -466,7 +466,7 @@ pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { } else { log::warn!( "MCP '{}' specified as boolean true — skipping. \ - Boolean-enabled MCPs required built-in MCPs which no longer exist. \ + Boolean-enabled MCPs require built-in MCPs which no longer exist. \ Use the object form with a command: field instead.", name ); diff --git a/src/mcp.rs b/src/mcp.rs index 3eb993c..3223537 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -527,10 +527,8 @@ pub async fn run_http( } } - axum::response::Response::builder() - .status(401) - .body(axum::body::Body::from("Unauthorized")) - .unwrap() + use axum::response::IntoResponse; + (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response() } })); From ffa258ca3a76ca4ca593234c7563dc49742d71d4 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 1 Apr 2026 10:28:33 +0100 Subject: [PATCH 16/19] refactor: inline MCPG image URL in template The MCPG image name (ghcr.io/github/gh-aw-mcpg) is a static string that belongs in the template, not compiled into the binary. Only the version number needs to be a compiled-in constant for template replacement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 11 +++++++++++ src/compile/common.rs | 1 - src/compile/standalone.rs | 3 +-- templates/base.yml | 4 ++-- tests/compiler_tests.rs | 4 ---- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c26b5b..1ecbbd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,6 +25,7 @@ dependencies = [ "serde_yaml", "subtle", "tempfile", + "terminal_size", "tokio", "url", ] @@ -1939,6 +1940,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" diff --git a/src/compile/common.rs b/src/compile/common.rs index 21bd391..42f0e57 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -469,7 +469,6 @@ pub fn generate_header_comment(input_path: &std::path::Path) -> String { /// Update this when upgrading to a new MCPG release. /// See: https://github.com/github/gh-aw-mcpg/releases pub const MCPG_VERSION: &str = "0.1.9"; -pub const MCPG_IMAGE: &str = "ghcr.io/github/gh-aw-mcpg"; /// Default port MCPG listens on inside the container (host network mode). pub const MCPG_PORT: u16 = 80; diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 068ef01..25eb531 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -14,7 +14,7 @@ use std::path::Path; use super::Compiler; use super::common::{ - self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, + self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, MCPG_PORT, MCPG_VERSION, compute_effective_workspace, generate_copilot_params, generate_acquire_ado_token, generate_cancel_previous_builds, generate_checkout_self, generate_checkout_steps, generate_ci_trigger, generate_copilot_ado_env, generate_executor_ado_env, @@ -146,7 +146,6 @@ impl Compiler for StandaloneCompiler { ("{{ compiler_version }}", compiler_version), ("{{ firewall_version }}", AWF_VERSION), ("{{ mcpg_version }}", MCPG_VERSION), - ("{{ mcpg_image }}", MCPG_IMAGE), ("{{ copilot_version }}", COPILOT_CLI_VERSION), ("{{ pool }}", &pool), ("{{ setup_job }}", &setup_job), diff --git a/templates/base.yml b/templates/base.yml index 80e2986..d7c1cbb 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -195,7 +195,7 @@ jobs: docker pull ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }} docker tag ghcr.io/github/gh-aw-firewall/squid:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/squid:latest docker tag ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/agent:latest - docker pull {{ mcpg_image }}:v{{ mcpg_version }} + docker pull ghcr.io/github/gh-aw-mcpg:v{{ mcpg_version }} displayName: "Pre-pull AWF and MCPG container images (v{{ firewall_version }})" {{ prepare_steps }} @@ -259,7 +259,7 @@ jobs: --network host \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ - {{ mcpg_image }}:v{{ mcpg_version }} & + ghcr.io/github/gh-aw-mcpg:v{{ mcpg_version }} & MCPG_PID=$! echo "##vso[task.setvariable variable=MCPG_PID]$MCPG_PID" echo "MCPG started (PID: $MCPG_PID)" diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 837ec0f..4896249 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -180,10 +180,6 @@ fn test_compiled_yaml_structure() { template_content.contains("{{ mcpg_config }}"), "Template should contain mcpg_config marker" ); - assert!( - template_content.contains("{{ mcpg_image }}"), - "Template should contain mcpg_image marker" - ); assert!( template_content.contains("{{ mcpg_version }}"), "Template should contain mcpg_version marker" From 58d104ffb4dd830d664a72756c3e9950bca4ab1c Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 1 Apr 2026 10:44:32 +0100 Subject: [PATCH 17/19] fix: pipeline template and docs fixes for SafeOutputs/MCPG - Add mkdir -p for staging/logs before SafeOutputs starts (prevents nohup redirect failure on every pipeline run) - Fix AGENTS.md SafeOutputs URL: host.docker.internal -> localhost (MCPG runs with --network host, so localhost is correct) - Remove unused MCPG_PID pipeline variable (cleanup uses docker stop by container name) - Add unit test for safeoutputs reserved-name collision guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 4 ++-- src/compile/standalone.rs | 21 +++++++++++++++++++++ templates/base.yml | 3 ++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca05d89..e664207 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -551,7 +551,7 @@ The generated JSON has two top-level sections: - `mcpServers`: Maps server names to their configuration (type, command/url, tools, etc.) - `gateway`: Gateway settings (port, domain, apiKey, payloadDir) -SafeOutputs is always included as an HTTP backend (`type: "http"`) pointing to `host.docker.internal`. Custom MCPs with explicit `command:` are included as stdio servers (`type: "stdio"`). MCPs without a command are skipped (there are no built-in MCPs in the copilot CLI). +SafeOutputs is always included as an HTTP backend (`type: "http"`) pointing to `localhost` (MCPG runs with `--network host`, so `localhost` is the host loopback). Custom MCPs with explicit `command:` are included as stdio servers (`type: "stdio"`). MCPs without a command are skipped (there are no built-in MCPs in the copilot CLI). Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline at runtime before passing the config to MCPG. @@ -1267,7 +1267,7 @@ The compiler generates MCPG configuration JSON from the `mcp-servers:` front mat "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://host.docker.internal:8100/mcp", + "url": "http://localhost:8100/mcp", "headers": { "Authorization": "Bearer " } diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 25eb531..44d0572 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -644,4 +644,25 @@ mod tests { assert_eq!(config.gateway.port, 80); assert_eq!(config.gateway.domain, "host.docker.internal"); } + + #[test] + fn test_generate_mcpg_config_safeoutputs_reserved_name_skipped() { + let mut fm = minimal_front_matter(); + fm.mcp_servers.insert( + "SafeOutputs".to_string(), + McpConfig::WithOptions(McpOptions { + command: Some("node".to_string()), + args: vec!["evil.js".to_string()], + allowed: vec!["hijack".to_string()], + ..Default::default() + }), + ); + let config = generate_mcpg_config(&fm); + // The user-defined "SafeOutputs" must not overwrite the built-in entry + let so = config.mcp_servers.get("safeoutputs").unwrap(); + assert_eq!(so.server_type, "http"); + assert!(so.url.as_ref().unwrap().contains("localhost")); + // No stdio entry should have been added under any casing + assert_eq!(config.mcp_servers.len(), 1); + } } diff --git a/templates/base.yml b/templates/base.yml index d7c1cbb..04ee0aa 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -207,6 +207,8 @@ jobs: echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + mkdir -p "$(Agent.TempDirectory)/staging/logs" + # Start SafeOutputs as HTTP server in the background nohup /tmp/awf-tools/ado-aw mcp-http \ --port "$SAFE_OUTPUTS_PORT" \ @@ -261,7 +263,6 @@ jobs: -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ ghcr.io/github/gh-aw-mcpg:v{{ mcpg_version }} & MCPG_PID=$! - echo "##vso[task.setvariable variable=MCPG_PID]$MCPG_PID" echo "MCPG started (PID: $MCPG_PID)" # Wait for MCPG to be ready From cd85b146b9c898c8f7b2ee4293cc8a5c046d79f8 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sat, 11 Apr 2026 10:23:41 +0100 Subject: [PATCH 18/19] Add tests for mcpg --- Cargo.lock | 2 + Cargo.toml | 8 +- src/compile/standalone.rs | 123 ++++++++++++ tests/mcp_http_tests.rs | 403 ++++++++++++++++++++++++++++++++++++++ tests/test_mcpg_local.sh | 263 +++++++++++++++++++++++++ 5 files changed, 797 insertions(+), 2 deletions(-) create mode 100644 tests/mcp_http_tests.rs create mode 100755 tests/test_mcpg_local.sh diff --git a/Cargo.lock b/Cargo.lock index 1ecbbd7..e9ab8bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1470,7 +1470,9 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", diff --git a/Cargo.toml b/Cargo.toml index 73530f1..d0d0940 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,13 @@ serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" serde_json = "1.0.149" schemars = "1.2" -rmcp = { version = "0.8.0", features = ["server", "transport-io", "transport-streamable-http-server"] } +rmcp = { version = "0.8.0", features = [ + "server", + "transport-io", + "transport-streamable-http-server", +] } percent-encoding = "2.3" -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "blocking"] } tempfile = "3" tokio = { version = "1.43", features = ["full"] } log = "0.4" diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 44d0572..5336f62 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -643,6 +643,129 @@ mod tests { let config = generate_mcpg_config(&fm); assert_eq!(config.gateway.port, 80); assert_eq!(config.gateway.domain, "host.docker.internal"); + assert_eq!(config.gateway.api_key, "${MCP_GATEWAY_API_KEY}"); + assert_eq!(config.gateway.payload_dir, "/tmp/gh-aw/mcp-payloads"); + } + + #[test] + fn test_generate_mcpg_config_json_roundtrip() { + let mut fm = minimal_front_matter(); + fm.mcp_servers.insert( + "my-tool".to_string(), + McpConfig::WithOptions(McpOptions { + command: Some("python".to_string()), + args: vec!["-m".to_string(), "server".to_string()], + allowed: vec!["query".to_string()], + ..Default::default() + }), + ); + let config = generate_mcpg_config(&fm); + let json = serde_json::to_string_pretty(&config) + .expect("Config should serialize to JSON"); + let parsed: serde_json::Value = + serde_json::from_str(&json).expect("Serialized JSON should parse back"); + + // Verify top-level structure matches MCPG expectation + assert!(parsed.get("mcpServers").is_some(), "Should have mcpServers key"); + assert!(parsed.get("gateway").is_some(), "Should have gateway key"); + + let gw = parsed.get("gateway").unwrap(); + assert!(gw.get("port").is_some(), "Gateway should have port"); + assert!(gw.get("domain").is_some(), "Gateway should have domain"); + assert!(gw.get("apiKey").is_some(), "Gateway should have apiKey"); + assert!(gw.get("payloadDir").is_some(), "Gateway should have payloadDir"); + } + + #[test] + fn test_generate_mcpg_config_safeoutputs_variable_placeholders() { + let fm = minimal_front_matter(); + let config = generate_mcpg_config(&fm); + let so = config.mcp_servers.get("safeoutputs").unwrap(); + + // URL should reference the runtime-substituted port + let url = so.url.as_ref().unwrap(); + assert!( + url.contains("${SAFE_OUTPUTS_PORT}"), + "SafeOutputs URL should use ${{SAFE_OUTPUTS_PORT}} placeholder, got: {url}" + ); + + // Auth header should reference the runtime-substituted API key + let headers = so.headers.as_ref().unwrap(); + let auth = headers.get("Authorization").unwrap(); + assert!( + auth.contains("${SAFE_OUTPUTS_API_KEY}"), + "SafeOutputs auth header should use ${{SAFE_OUTPUTS_API_KEY}} placeholder, got: {auth}" + ); + } + + #[test] + fn test_generate_mcpg_config_safeoutputs_is_http_type() { + let fm = minimal_front_matter(); + let config = generate_mcpg_config(&fm); + let so = config.mcp_servers.get("safeoutputs").unwrap(); + assert_eq!(so.server_type, "http"); + assert!(so.command.is_none(), "HTTP backend should have no command"); + assert!(so.args.is_none(), "HTTP backend should have no args"); + assert!(so.url.is_some(), "HTTP backend must have a URL"); + } + + #[test] + fn test_generate_mcpg_config_custom_mcp_is_stdio_type() { + let mut fm = minimal_front_matter(); + fm.mcp_servers.insert( + "runner".to_string(), + McpConfig::WithOptions(McpOptions { + command: Some("node".to_string()), + args: vec!["srv.js".to_string()], + allowed: vec!["run".to_string()], + ..Default::default() + }), + ); + let config = generate_mcpg_config(&fm); + let srv = config.mcp_servers.get("runner").unwrap(); + assert_eq!(srv.server_type, "stdio"); + assert!(srv.command.is_some(), "stdio server must have a command"); + assert!(srv.url.is_none(), "stdio server should have no URL"); + } + + #[test] + fn test_generate_mcpg_config_custom_mcp_with_env() { + let mut fm = minimal_front_matter(); + let mut env = std::collections::HashMap::new(); + env.insert("TOKEN".to_string(), "secret".to_string()); + fm.mcp_servers.insert( + "with-env".to_string(), + McpConfig::WithOptions(McpOptions { + command: Some("node".to_string()), + args: vec![], + allowed: vec![], + env, + ..Default::default() + }), + ); + let config = generate_mcpg_config(&fm); + let srv = config.mcp_servers.get("with-env").unwrap(); + let e = srv.env.as_ref().unwrap(); + assert_eq!(e.get("TOKEN").unwrap(), "secret"); + } + + #[test] + fn test_generate_mcpg_config_reserved_safeoutputs_name_rejected() { + let mut fm = minimal_front_matter(); + fm.mcp_servers.insert( + "safeoutputs".to_string(), + McpConfig::WithOptions(McpOptions { + command: Some("evil".to_string()), + args: vec![], + allowed: vec![], + ..Default::default() + }), + ); + let config = generate_mcpg_config(&fm); + // The reserved entry should still be the HTTP backend, not the user's command + let so = config.mcp_servers.get("safeoutputs").unwrap(); + assert_eq!(so.server_type, "http", "safeoutputs should remain HTTP backend"); + assert!(so.command.is_none(), "User command should not overwrite safeoutputs"); } #[test] diff --git a/tests/mcp_http_tests.rs b/tests/mcp_http_tests.rs new file mode 100644 index 0000000..9b6b781 --- /dev/null +++ b/tests/mcp_http_tests.rs @@ -0,0 +1,403 @@ +use std::io::BufRead; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +/// Integration tests for the SafeOutputs HTTP server (`mcp-http` subcommand). +/// +/// These tests validate the HTTP transport layer that MCPG uses to reach +/// SafeOutputs. They do NOT require Docker or the MCPG gateway — they test +/// the ado-aw HTTP server directly. + +/// Guard that kills the child process on drop (even on panic). +struct ServerGuard { + child: Child, + port: u16, + api_key: String, + _temp_dir: tempfile::TempDir, + #[allow(dead_code)] + stderr_thread: Option>, +} + +impl Drop for ServerGuard { + fn drop(&mut self) { + self.child.kill().ok(); + self.child.wait().ok(); + } +} + +/// Helper: find a free TCP port on localhost. +fn free_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() +} + +/// Start SafeOutputs HTTP server as a subprocess. Returns a guard that stops +/// the server on drop. +fn start_server() -> ServerGuard { + let binary_path = env!("CARGO_BIN_EXE_ado-aw"); + let port = free_port(); + let api_key = "test-api-key-12345".to_string(); + let temp_dir = tempfile::tempdir().unwrap(); + let dir_path = temp_dir.path().to_str().unwrap().to_string(); + + let mut cmd = Command::new(binary_path); + cmd.args([ + "mcp-http", + "--port", + &port.to_string(), + "--api-key", + &api_key, + &dir_path, + &dir_path, + ]); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn().expect("Failed to start mcp-http server"); + + // Consume stdout to read the startup output (SAFE_OUTPUTS_PORT=...) + let stdout = child.stdout.take().expect("Failed to capture stdout"); + let _stdout_thread = std::thread::spawn(move || { + let reader = std::io::BufReader::new(stdout); + for line in reader.lines() { + if line.is_err() { + break; + } + } + }); + + // Consume stderr to prevent buffer fill-up + let stderr = child.stderr.take().expect("Failed to capture stderr"); + let stderr_thread = std::thread::spawn(move || { + let reader = std::io::BufReader::new(stderr); + for line in reader.lines() { + if line.is_err() { + break; + } + } + }); + + // Wait for the server to become ready (up to 5 s) + let health_url = format!("http://127.0.0.1:{}/health", port); + let client = reqwest::blocking::Client::new(); + for _ in 0..50 { + if client.get(&health_url).send().is_ok() { + return ServerGuard { + child, + port, + api_key, + _temp_dir: temp_dir, + stderr_thread: Some(stderr_thread), + }; + } + std::thread::sleep(Duration::from_millis(100)); + } + // Kill and panic if not ready + child.kill().ok(); + panic!("SafeOutputs HTTP server did not become ready within 5 s"); +} + +/// Send a JSON-RPC request to the SafeOutputs MCP endpoint. +fn mcp_request( + client: &reqwest::blocking::Client, + server: &ServerGuard, + body: serde_json::Value, + session_id: Option<&str>, +) -> reqwest::blocking::Response { + let mut req = client + .post(format!("http://127.0.0.1:{}/mcp", server.port)) + .header("Authorization", format!("Bearer {}", server.api_key)) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream, application/json"); + if let Some(sid) = session_id { + req = req.header("mcp-session-id", sid); + } + req.json(&body).send().expect("Failed to send MCP request") +} + +/// Perform the MCP initialize + initialized handshake, return session ID. +fn mcp_handshake(client: &reqwest::blocking::Client, server: &ServerGuard) -> Option { + let init_resp = mcp_request( + client, + server, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0" } + } + }), + None, + ); + assert!( + init_resp.status().is_success(), + "Initialize should succeed, got {}", + init_resp.status() + ); + + let session_id = init_resp + .headers() + .get("mcp-session-id") + .map(|v| v.to_str().unwrap().to_string()); + + // Send initialized notification + let mut notif_req = client + .post(format!("http://127.0.0.1:{}/mcp", server.port)) + .header("Authorization", format!("Bearer {}", server.api_key)) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream, application/json"); + if let Some(ref sid) = session_id { + notif_req = notif_req.header("mcp-session-id", sid); + } + let _ = notif_req + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + })) + .send() + .unwrap(); + + session_id +} + +// ────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────── + +#[test] +fn test_health_endpoint_returns_ok() { + let server = start_server(); + let client = reqwest::blocking::Client::new(); + let resp = client + .get(format!("http://127.0.0.1:{}/health", server.port)) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().unwrap(), "ok"); +} + +#[test] +fn test_auth_rejects_missing_token() { + let server = start_server(); + let client = reqwest::blocking::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/mcp", server.port)) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + })) + .send() + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[test] +fn test_auth_rejects_wrong_token() { + let server = start_server(); + let client = reqwest::blocking::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/mcp", server.port)) + .header("Authorization", "Bearer wrong-key") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + })) + .send() + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[test] +fn test_auth_accepts_correct_token() { + let server = start_server(); + let client = reqwest::blocking::Client::new(); + let resp = mcp_request( + &client, + &server, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0" } + } + }), + None, + ); + assert_ne!(resp.status(), 401, "Correct API key should not be rejected"); +} + +#[test] +fn test_health_endpoint_no_auth_required() { + let server = start_server(); + // Health endpoint should work without any auth header + let client = reqwest::blocking::Client::new(); + let resp = client + .get(format!("http://127.0.0.1:{}/health", server.port)) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); +} + +#[test] +fn test_mcp_initialize_and_tools_list() { + let server = start_server(); + let client = reqwest::blocking::Client::new(); + let session_id = mcp_handshake(&client, &server); + + // List tools + let mut tools_req = client + .post(format!("http://127.0.0.1:{}/mcp", server.port)) + .header("Authorization", format!("Bearer {}", server.api_key)) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream, application/json"); + if let Some(ref sid) = session_id { + tools_req = tools_req.header("mcp-session-id", sid); + } + let tools_resp = tools_req + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .send() + .unwrap(); + + assert!( + tools_resp.status().is_success(), + "tools/list should succeed, got {}", + tools_resp.status() + ); + + let body = tools_resp.text().unwrap(); + + // The response should contain our known tools + assert!(body.contains("noop"), "Should list noop tool, body: {body}"); + assert!( + body.contains("create-work-item"), + "Should list create-work-item tool, body: {body}" + ); + assert!( + body.contains("create-pull-request"), + "Should list create-pull-request tool, body: {body}" + ); + assert!( + body.contains("missing-tool"), + "Should list missing-tool tool, body: {body}" + ); + assert!( + body.contains("missing-data"), + "Should list missing-data tool, body: {body}" + ); +} + +#[test] +fn test_mcp_call_noop_tool() { + let server = start_server(); + let client = reqwest::blocking::Client::new(); + let session_id = mcp_handshake(&client, &server); + + // Call noop tool + let call_resp = mcp_request( + &client, + &server, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "noop", + "arguments": { + "context": "Test run - no action needed" + } + } + }), + session_id.as_deref(), + ); + + assert!( + call_resp.status().is_success(), + "tools/call noop should succeed, got {}", + call_resp.status() + ); + + // Consume the full response body (SSE stream) to ensure the server-side + // handler has completed before we check the NDJSON file. + let _body = call_resp.text().unwrap(); + + // Verify NDJSON file was written + std::thread::sleep(Duration::from_millis(500)); + let ndjson_path = server._temp_dir.path().join("safe_outputs.ndjson"); + assert!( + ndjson_path.exists(), + "Safe outputs NDJSON file should exist at {:?}", + ndjson_path + ); + + let content = std::fs::read_to_string(&ndjson_path).unwrap(); + assert!( + content.contains("noop"), + "NDJSON should contain noop entry: {content}" + ); +} + +#[test] +fn test_mcp_call_create_work_item() { + let server = start_server(); + let client = reqwest::blocking::Client::new(); + let session_id = mcp_handshake(&client, &server); + + // Call create-work-item + let call_resp = mcp_request( + &client, + &server, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "create-work-item", + "arguments": { + "title": "Test work item from integration test", + "description": "This is a test work item created during integration testing of the SafeOutputs HTTP server." + } + } + }), + session_id.as_deref(), + ); + + assert!( + call_resp.status().is_success(), + "tools/call create-work-item should succeed, got {}", + call_resp.status() + ); + + // Consume the full SSE response to ensure handler completion + let _body = call_resp.text().unwrap(); + + // Verify NDJSON file contains the work item + std::thread::sleep(Duration::from_millis(500)); + let ndjson_path = server._temp_dir.path().join("safe_outputs.ndjson"); + let content = std::fs::read_to_string(&ndjson_path).unwrap(); + assert!( + content.contains("create-work-item"), + "NDJSON should contain create-work-item entry: {content}" + ); + assert!( + content.contains("Test work item from integration test"), + "NDJSON should contain work item title: {content}" + ); +} + diff --git a/tests/test_mcpg_local.sh b/tests/test_mcpg_local.sh new file mode 100755 index 0000000..aada8ff --- /dev/null +++ b/tests/test_mcpg_local.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# test_mcpg_local.sh — Local smoke test for MCPG integration (no Docker required) +# +# This script validates the ado-aw components that interface with MCPG: +# 1. Compiles a sample agent and verifies MCPG markers in output YAML +# 2. Starts the SafeOutputs HTTP server +# 3. Sends MCP requests via curl (simulating MCPG forwarding) +# 4. Verifies NDJSON safe output files are created +# +# Usage: +# ./tests/test_mcpg_local.sh +# ./tests/test_mcpg_local.sh --skip-compile # skip compilation step + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TEMP_DIR=$(mktemp -d) +BINARY="" +SO_PID="" + +cleanup() { + if [ -n "$SO_PID" ]; then + kill "$SO_PID" 2>/dev/null || true + wait "$SO_PID" 2>/dev/null || true + fi + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT + +log() { echo "==> $*"; } +pass() { echo " ✅ $*"; } +fail() { echo " ❌ $*"; exit 1; } + +# ─── Build ─────────────────────────────────────────────────────────── +log "Building ado-aw..." +cd "$PROJECT_DIR" +cargo build --quiet 2>/dev/null +BINARY="$PROJECT_DIR/target/debug/ado-aw" + +if [ ! -x "$BINARY" ]; then + fail "Binary not found at $BINARY" +fi +pass "Binary built: $BINARY" + +# ─── Step 1: Compile a sample agent ───────────────────────────────── +if [ "${1:-}" != "--skip-compile" ]; then + log "Step 1: Compiling sample agent..." + + FIXTURE="$SCRIPT_DIR/fixtures/minimal-agent.md" + OUTPUT_YAML="$TEMP_DIR/minimal-agent.yml" + + "$BINARY" compile "$FIXTURE" -o "$OUTPUT_YAML" + + if [ ! -f "$OUTPUT_YAML" ]; then + fail "Compiled YAML not created" + fi + + # Verify MCPG markers are resolved + if grep -q 'ghcr.io/github/gh-aw-mcpg' "$OUTPUT_YAML"; then + pass "MCPG image reference present" + else + fail "MCPG image reference missing" + fi + + if grep -q 'mcpg-config.json' "$OUTPUT_YAML"; then + pass "MCPG config file reference present" + else + fail "MCPG config file reference missing" + fi + + if grep -q 'host.docker.internal' "$OUTPUT_YAML"; then + pass "host.docker.internal reference present" + else + fail "host.docker.internal reference missing" + fi + + if grep -q 'enable-host-access' "$OUTPUT_YAML"; then + pass "AWF --enable-host-access flag present" + else + fail "AWF --enable-host-access flag missing" + fi + + if grep -q 'SafeOutputs HTTP server' "$OUTPUT_YAML"; then + pass "SafeOutputs HTTP server step present" + else + fail "SafeOutputs HTTP server step missing" + fi + + # Verify no unreplaced markers + if grep -v '\${{' "$OUTPUT_YAML" | grep -q '{{ '; then + fail "Unreplaced template markers found in compiled output" + else + pass "No unreplaced template markers" + fi + + # Verify no legacy MCP firewall references + if grep -qi 'mcp-firewall\|mcp_firewall' "$OUTPUT_YAML"; then + fail "Legacy MCP firewall references found" + else + pass "No legacy MCP firewall references" + fi +else + log "Step 1: Skipping compilation (--skip-compile)" +fi + +# ─── Step 2: Start SafeOutputs HTTP server ────────────────────────── +log "Step 2: Starting SafeOutputs HTTP server..." + +SO_DIR="$TEMP_DIR/safe-outputs" +mkdir -p "$SO_DIR" + +PORT=8199 +API_KEY="test-smoke-key-$(date +%s)" + +"$BINARY" mcp-http --port "$PORT" --api-key "$API_KEY" "$SO_DIR" "$SO_DIR" & +SO_PID=$! + +# Wait for server to be ready +READY=false +for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then + READY=true + break + fi + sleep 0.2 +done + +if [ "$READY" != "true" ]; then + fail "SafeOutputs HTTP server did not become ready" +fi +pass "SafeOutputs HTTP server running on port $PORT (PID: $SO_PID)" + +# ─── Step 3: Health check ─────────────────────────────────────────── +log "Step 3: Verifying health endpoint..." + +HEALTH=$(curl -sf "http://127.0.0.1:$PORT/health") +if [ "$HEALTH" = "ok" ]; then + pass "Health endpoint returns 'ok'" +else + fail "Health endpoint returned: $HEALTH" +fi + +# ─── Step 4: Auth check ───────────────────────────────────────────── +log "Step 4: Verifying auth enforcement..." + +HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \ + -X POST "http://127.0.0.1:$PORT/mcp" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream, application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}') + +if [ "$HTTP_CODE" = "401" ]; then + pass "Unauthenticated request rejected (401)" +else + fail "Expected 401, got $HTTP_CODE" +fi + +# ─── Step 5: MCP Initialize ───────────────────────────────────────── +log "Step 5: MCP Initialize handshake..." + +INIT_RESP=$(curl -sf -D "$TEMP_DIR/init-headers.txt" \ + -X POST "http://127.0.0.1:$PORT/mcp" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream, application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}}}') + +SESSION_ID=$(grep -i 'mcp-session-id' "$TEMP_DIR/init-headers.txt" | tr -d '\r' | awk '{print $2}' || true) + +if [ -n "$SESSION_ID" ]; then + pass "Session initialized (ID: ${SESSION_ID:0:16}...)" +else + log " Warning: No session ID returned (stateless mode)" +fi + +# Send initialized notification +curl -sf -o /dev/null \ + -X POST "http://127.0.0.1:$PORT/mcp" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream, application/json" \ + ${SESSION_ID:+-H "mcp-session-id: $SESSION_ID"} \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' || true + +pass "Initialized notification sent" + +# ─── Step 6: tools/list ───────────────────────────────────────────── +log "Step 6: Listing available tools..." + +TOOLS_RESP=$(curl -sf \ + -X POST "http://127.0.0.1:$PORT/mcp" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream, application/json" \ + ${SESSION_ID:+-H "mcp-session-id: $SESSION_ID"} \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}') + +for tool in noop create-work-item create-pull-request missing-tool missing-data; do + if echo "$TOOLS_RESP" | grep -q "$tool"; then + pass "Tool '$tool' available" + else + fail "Tool '$tool' not found in tools/list response" + fi +done + +# ─── Step 7: tools/call noop ──────────────────────────────────────── +log "Step 7: Calling noop tool..." + +NOOP_RESP=$(curl -sf \ + -X POST "http://127.0.0.1:$PORT/mcp" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream, application/json" \ + ${SESSION_ID:+-H "mcp-session-id: $SESSION_ID"} \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"noop","arguments":{"context":"Smoke test - all good"}}}') + +sleep 0.5 + +NDJSON="$SO_DIR/safe_outputs.ndjson" +if [ -f "$NDJSON" ]; then + pass "NDJSON file created: $NDJSON" +else + fail "NDJSON file not found" +fi + +if grep -q '"noop"' "$NDJSON"; then + pass "Noop entry found in NDJSON" +else + fail "Noop entry not in NDJSON. Content: $(cat "$NDJSON")" +fi + +# ─── Step 8: tools/call create-work-item ──────────────────────────── +log "Step 8: Calling create-work-item tool..." + +WI_RESP=$(curl -sf \ + -X POST "http://127.0.0.1:$PORT/mcp" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream, application/json" \ + ${SESSION_ID:+-H "mcp-session-id: $SESSION_ID"} \ + -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"create-work-item","arguments":{"title":"Smoke test work item title","description":"This is a smoke test work item with enough description length."}}}') + +sleep 0.5 + +if grep -q '"create-work-item"' "$NDJSON"; then + pass "Work item entry found in NDJSON" +else + fail "Work item entry not in NDJSON" +fi + +if grep -q 'Smoke test work item title' "$NDJSON"; then + pass "Work item title preserved in NDJSON" +else + fail "Work item title not found in NDJSON" +fi + +# ─── Summary ──────────────────────────────────────────────────────── +echo "" +log "All smoke tests passed! ✅" +echo "" +echo "NDJSON contents:" +cat "$NDJSON" From 5fa9f513e2cd1a61a6b72bcb3484f23f5f650ed0 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sat, 11 Apr 2026 10:42:36 +0100 Subject: [PATCH 19/19] Merge main --- .github/agents/agentic-workflows.agent.md | 21 +- .github/aw/actions-lock.json | 23 +- .github/workflows/copilot-setup-steps.yml | 4 +- .../workflows/doc-freshness-check.lock.yml | 828 +++++++----- .github/workflows/doc-freshness-check.md | 18 +- .github/workflows/release.yml | 2 - .github/workflows/rust-pr-reviewer.lock.yml | 820 +++++++----- .../workflows/rust-review-command.lock.yml | 885 +++++++------ .github/workflows/test-gap-finder.lock.yml | 872 +++++++------ .github/workflows/update-awf-version.lock.yml | 870 +++++++------ .github/workflows/update-awf-version.md | 91 +- .release-please-manifest.json | 3 + AGENTS.md | 99 +- CHANGELOG.md | 71 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- prompts/create-ado-agentic-workflow.md | 1 - release-please-config.json | 10 + src/compile/common.rs | 675 +++++++++- src/compile/mod.rs | 89 +- src/compile/onees.rs | 46 +- src/compile/standalone.rs | 127 +- src/compile/types.rs | 5 +- src/execute.rs | 125 +- src/main.rs | 8 +- src/mcp.rs | 297 ++++- src/ndjson.rs | 3 + src/sanitize.rs | 60 + src/tools/add_build_tag.rs | 316 +++++ src/tools/add_pr_comment.rs | 610 +++++++++ src/tools/create_branch.rs | 495 ++++++++ src/tools/create_git_tag.rs | 514 ++++++++ src/tools/create_wiki_page.rs | 64 +- src/tools/link_work_items.rs | 460 +++++++ src/tools/mod.rs | 186 ++- src/tools/queue_build.rs | 500 ++++++++ src/tools/reply_to_pr_comment.rs | 346 +++++ src/tools/report_incomplete.rs | 105 ++ src/tools/resolve_pr_thread.rs | 404 ++++++ src/tools/submit_pr_review.rs | 545 ++++++++ src/tools/update_pr.rs | 1131 +++++++++++++++++ src/tools/update_wiki_page.rs | 64 +- src/tools/upload_attachment.rs | 528 ++++++++ templates/1es-base.yml | 9 +- templates/base.yml | 22 +- tests/EXAMPLES.md | 8 +- tests/QUICKREF.md | 2 +- tests/README.md | 8 +- tests/SUMMARY.md | 10 +- tests/VERIFICATION.md | 2 +- tests/compiler_tests.rs | 310 ++++- 51 files changed, 10633 insertions(+), 2063 deletions(-) create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json create mode 100644 src/tools/add_build_tag.rs create mode 100644 src/tools/add_pr_comment.rs create mode 100644 src/tools/create_branch.rs create mode 100644 src/tools/create_git_tag.rs create mode 100644 src/tools/link_work_items.rs create mode 100644 src/tools/queue_build.rs create mode 100644 src/tools/reply_to_pr_comment.rs create mode 100644 src/tools/report_incomplete.rs create mode 100644 src/tools/resolve_pr_thread.rs create mode 100644 src/tools/submit_pr_review.rs create mode 100644 src/tools/update_pr.rs create mode 100644 src/tools/upload_attachment.rs diff --git a/.github/agents/agentic-workflows.agent.md b/.github/agents/agentic-workflows.agent.md index 94dc771..ef13785 100644 --- a/.github/agents/agentic-workflows.agent.md +++ b/.github/agents/agentic-workflows.agent.md @@ -30,7 +30,7 @@ Workflows may optionally include: - Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md` - Workflow lock files: `.github/workflows/*.lock.yml` - Shared components: `.github/workflows/shared/*.md` -- Configuration: https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/github-agentic-workflows.md +- Configuration: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/github-agentic-workflows.md ## Problems This Solves @@ -52,7 +52,7 @@ When you interact with this agent, it will: ### Create New Workflow **Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet -**Prompt file**: https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/create-agentic-workflow.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/create-agentic-workflow.md **Use cases**: - "Create a workflow that triages issues" @@ -62,7 +62,7 @@ When you interact with this agent, it will: ### Update Existing Workflow **Load when**: User wants to modify, improve, or refactor an existing workflow -**Prompt file**: https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/update-agentic-workflow.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/update-agentic-workflow.md **Use cases**: - "Add web-fetch tool to the issue-classifier workflow" @@ -72,7 +72,7 @@ When you interact with this agent, it will: ### Debug Workflow **Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors -**Prompt file**: https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/debug-agentic-workflow.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/debug-agentic-workflow.md **Use cases**: - "Why is this workflow failing?" @@ -82,7 +82,7 @@ When you interact with this agent, it will: ### Upgrade Agentic Workflows **Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations -**Prompt file**: https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/upgrade-agentic-workflows.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/upgrade-agentic-workflows.md **Use cases**: - "Upgrade all workflows to the latest version" @@ -92,7 +92,7 @@ When you interact with this agent, it will: ### Create a Report-Generating Workflow **Load when**: The workflow being created or updated produces reports — recurring status updates, audit summaries, analyses, or any structured output posted as a GitHub issue, discussion, or comment -**Prompt file**: https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/report.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/report.md **Use cases**: - "Create a weekly CI health report" @@ -102,7 +102,7 @@ When you interact with this agent, it will: ### Create Shared Agentic Workflow **Load when**: User wants to create a reusable workflow component or wrap an MCP server -**Prompt file**: https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/create-shared-agentic-workflow.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/create-shared-agentic-workflow.md **Use cases**: - "Create a shared component for Notion integration" @@ -112,7 +112,7 @@ When you interact with this agent, it will: ### Fix Dependabot PRs **Load when**: User needs to close or fix open Dependabot PRs that update dependencies in generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`) -**Prompt file**: https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/dependabot.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/dependabot.md **Use cases**: - "Fix the open Dependabot PRs for npm dependencies" @@ -122,7 +122,7 @@ When you interact with this agent, it will: ### Analyze Test Coverage **Load when**: The workflow reads, analyzes, or reports test coverage — whether triggered by a PR, a schedule, or a slash command. Always consult this prompt before designing the coverage data strategy. -**Prompt file**: https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/test-coverage.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/test-coverage.md **Use cases**: - "Create a workflow that comments coverage on PRs" @@ -169,9 +169,10 @@ gh aw compile --validate ## Important Notes -- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.53.6/.github/aw/github-agentic-workflows.md for complete documentation +- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/github-agentic-workflows.md for complete documentation - Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud - Workflows must be compiled to `.lock.yml` files before running in GitHub Actions - **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF - Follow security best practices: minimal permissions, explicit network access, no template injection +- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/network.md for the full list of valid ecosystem identifiers and domain patterns. - **Single-file output**: When creating a workflow, produce exactly **one** workflow `.md` file. Do not create separate documentation files (architecture docs, runbooks, usage guides, etc.). If documentation is needed, add a brief `## Usage` section inside the workflow file itself. diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 0d93b00..f06d2c0 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -1,19 +1,24 @@ { "entries": { - "actions/github-script@v8": { + "actions/github-script@v9": { "repo": "actions/github-script", - "version": "v8", - "sha": "ed597411d8f924073f98dfc5c65a23a2325f34cd" + "version": "v9", + "sha": "373c709c69115d41ff229c7e5df9f8788daa9553" }, - "github/gh-aw-actions/setup@v0.62.0": { + "actions/github-script@v9.0.0": { + "repo": "actions/github-script", + "version": "v9.0.0", + "sha": "d746ffe35508b1917358783b479e04febd2b8f71" + }, + "github/gh-aw-actions/setup@v0.68.1": { "repo": "github/gh-aw-actions/setup", - "version": "v0.62.0", - "sha": "b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6" + "version": "v0.68.1", + "sha": "2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc" }, - "github/gh-aw/actions/setup@v0.53.6": { + "github/gh-aw/actions/setup@v0.68.1": { "repo": "github/gh-aw/actions/setup", - "version": "v0.53.6", - "sha": "956f874e40e831c08a8b01ec76f5d49ae3fe8387" + "version": "v0.68.1", + "sha": "5a06d310cf45161bde77d070065a1e1489fc411c" } } } diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 5768ca9..1d6957b 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -21,6 +21,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - name: Install gh-aw extension - uses: github/gh-aw/actions/setup-cli@956f874e40e831c08a8b01ec76f5d49ae3fe8387 # v0.53.6 + uses: github/gh-aw-actions/setup-cli@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: - version: v0.53.6 + version: v0.68.1 diff --git a/.github/workflows/doc-freshness-check.lock.yml b/.github/workflows/doc-freshness-check.lock.yml index 6b97953..4381ded 100644 --- a/.github/workflows/doc-freshness-check.lock.yml +++ b/.github/workflows/doc-freshness-check.lock.yml @@ -1,3 +1,5 @@ +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"8b2d61f7754d692646c2ed658f698a26ac07b1b5eb08cf566ab421fa94614171","compiler_version":"v0.68.1","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9"},{"repo":"actions/upload-artifact","sha":"bbbca2ddaa5d8feaa63e36b76fdaad77386f024f","version":"v7"},{"repo":"github/gh-aw-actions/setup","sha":"2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc","version":"v0.68.1"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -12,7 +14,7 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.62.0). DO NOT EDIT. +# This file was automatically generated by gh-aw (v0.68.1). DO NOT EDIT. # # To update this file, edit the corresponding .md file and run: # gh aw compile @@ -22,14 +24,32 @@ # # Checks that documentation stays consistent with code structure and CLI commands # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"8b2d61f7754d692646c2ed658f698a26ac07b1b5eb08cf566ab421fa94614171","compiler_version":"v0.62.0","strict":true} +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 +# - actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 +# - github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 name: "Documentation Freshness Check" "on": schedule: - - cron: "43 22 * * 1-5" + - cron: "30 12 * * 1-5" # Friendly format: daily on weekdays (scattered) workflow_dispatch: + inputs: + aw_context: + default: "" + description: Agent caller context (used internally by Agentic Workflows). + required: false + type: string permissions: {} @@ -42,6 +62,7 @@ jobs: activation: runs-on: ubuntu-slim permissions: + actions: read contents: read outputs: comment_id: "" @@ -49,40 +70,44 @@ jobs: lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} - name: Generate agentic run info id: generate_aw_info env: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_INFO_VERSION: "" - GH_AW_INFO_AGENT_VERSION: "latest" - GH_AW_INFO_CLI_VERSION: "v0.62.0" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" + GH_AW_INFO_CLI_VERSION: "v0.68.1" GH_AW_INFO_WORKFLOW_NAME: "Documentation Freshness Check" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","rust"]' GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.24.3" + GH_AW_INFO_AWF_VERSION: "v0.25.18" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Validate COPILOT_GITHUB_TOKEN secret id: validate-secret - run: ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders @@ -94,20 +119,32 @@ jobs: .agents sparse-checkout-cone-mode: true fetch-depth: 1 - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_WORKFLOW_FILE: "doc-freshness-check.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_COMPILED_VERSION: "v0.68.1" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} @@ -116,17 +153,18 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec run: | - bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_dddfcfb12bba151d_EOF' - GH_AW_PROMPT_EOF + GH_AW_PROMPT_dddfcfb12bba151d_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_dddfcfb12bba151d_EOF' Tools: create_issue, missing_tool, missing_data, noop @@ -158,27 +196,25 @@ jobs: {{/if}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_dddfcfb12bba151d_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_dddfcfb12bba151d_EOF' - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/doc-freshness-check.md}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_dddfcfb12bba151d_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); await main(); - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_GITHUB_ACTOR: ${{ github.actor }} @@ -192,7 +228,7 @@ jobs: with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); @@ -213,19 +249,23 @@ jobs: - name: Validate prompt placeholders env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" - name: Upload activation artifact if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: activation path: | /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/github_rate_limits.jsonl + if-no-files-found: ignore retention-days: 1 agent: @@ -246,68 +286,73 @@ jobs: GH_AW_WORKFLOW_ID_SANITIZED: docfreshnesscheck outputs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} - detection_success: ${{ steps.detection_conclusion.outputs.success }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} has_patch: ${{ steps.collect_output.outputs.has_patch }} inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} model: ${{ needs.activation.outputs.model }} output: ${{ steps.collect_output.outputs.output }} output_types: ${{ steps.collect_output.outputs.output_types }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Set runtime paths + id: set-runtime-paths run: | - echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_ENV" + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Create gh-aw temp directory - run: bash ${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise - run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" env: GH_TOKEN: ${{ github.token }} - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Checkout PR branch id: checkout-pr if: | - (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 env: GH_HOST: github.com - name: Install AWF binary - run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.3 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 env: GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} @@ -316,121 +361,141 @@ jobs: const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.3 ghcr.io/github/gh-aw-firewall/squid:0.24.3 ghcr.io/github/gh-aw-mcpg:v0.1.19 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 ghcr.io/github/gh-aw-mcpg:v0.2.17 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config run: | - mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"create_issue":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_f9d1950d6933f20b_EOF' + {"create_issue":{"max":1},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_f9d1950d6933f20b_EOF - name: Write Safe Outputs Tools - run: | - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF' - { - "description_suffixes": { - "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created." - }, - "repo_params": {}, - "dynamic_tools": [] - } - GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "create_issue": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "parent": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "temporary_id": { - "type": "string" - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } } - } - }, - "missing_data": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "context": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "data_type": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "reason": { - "type": "string", - "sanitize": true, - "maxLength": 256 + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } } } } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); - name: Generate Safe Outputs MCP Server Config id: safe-outputs-config run: | @@ -453,6 +518,7 @@ jobs: id: safe-outputs-start env: DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json @@ -461,18 +527,19 @@ jobs: run: | # Environment variables are set above to prevent template injection export DEBUG + export GH_AW_SAFE_OUTPUTS export GH_AW_SAFE_OUTPUTS_PORT export GH_AW_SAFE_OUTPUTS_API_KEY export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" - name: Start MCP Gateway id: start-mcp-gateway env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} @@ -494,10 +561,10 @@ jobs: export DEBUG="*" export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.19' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.17' mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_de1e9977fc7fe512_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh" { "mcpServers": { "github": { @@ -538,7 +605,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_EOF + GH_AW_MCP_CONFIG_de1e9977fc7fe512_EOF - name: Download activation artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -546,7 +613,7 @@ jobs: path: /tmp/gh-aw - name: Clean git credentials continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): @@ -554,9 +621,10 @@ jobs: run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} @@ -564,8 +632,8 @@ jobs: GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_VERSION: v0.62.0 + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.68.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_HEAD_REF: ${{ github.head_ref }} @@ -583,36 +651,24 @@ jobs: id: detect-inference-error if: always() continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh" - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Copy Copilot session state files to logs if: always() continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" - name: Stop MCP Gateway if: always() continue-on-error: true @@ -621,14 +677,14 @@ jobs: MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} run: | - bash ${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" - name: Redact secrets in logs if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); await main(); env: @@ -639,45 +695,48 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Append agent step summary if: always() - run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" - name: Copy Safe Outputs if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} run: | mkdir -p /tmp/gh-aw cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true - name: Ingest agent output id: collect_output if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); await main(); - name: Parse agent logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); await main(); - name: Parse MCP Gateway logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); await main(); - name: Print firewall logs @@ -695,10 +754,26 @@ jobs: else echo 'AWF binary not installed, skipping firewall log summary' fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: agent path: | @@ -706,19 +781,189 @@ jobs: /tmp/gh-aw/sandbox/agent/logs/ /tmp/gh-aw/redacted-urls.log /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent-stdio.log /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/safeoutputs.jsonl /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle if-no-files-found: ignore - # --- Threat Detection (inline) --- + - name: Upload firewall audit logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: firewall-audit-logs + path: | + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + concurrency: + group: "gh-aw-conclusion-doc-freshness-check" + cancel-in-progress: false + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Documentation Freshness Check" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Documentation Freshness Check" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Documentation Freshness Check" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Documentation Freshness Check" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "doc-freshness-check" + GH_AW_ENGINE_ID: "copilot" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + detection: + needs: + - activation + - agent + if: > + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 - name: Check if detection needed id: detection_guard if: always() env: - OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} run: | if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then echo "run_detection=true" >> "$GITHUB_OUTPUT" @@ -742,19 +987,22 @@ jobs: for f in /tmp/gh-aw/aw-*.patch; do [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done echo "Prepared threat detection files:" ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: WORKFLOW_NAME: "Documentation Freshness Check" WORKFLOW_DESCRIPTION: "Checks that documentation stays consistent with code structure and CLI commands" - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); await main(); - name: Ensure threat-detection directory and log @@ -762,31 +1010,31 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 - name: Execute GitHub Copilot CLI if: always() && steps.detection_guard.outputs.run_detection == 'true' id: detection_agentic_execution # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) timeout-minutes: 20 run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,telemetry.enterprise.githubcopilot.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_VERSION: v0.62.0 + GH_AW_VERSION: v0.68.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_HEAD_REF: ${{ github.head_ref }} @@ -799,151 +1047,32 @@ jobs: GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_detection_results - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - name: Upload threat detection log if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: detection path: /tmp/gh-aw/threat-detection/detection.log if-no-files-found: ignore - - name: Set detection conclusion + - name: Parse and conclude threat detection id: detection_conclusion if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} - DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} - run: | - if [[ "$RUN_DETECTION" != "true" ]]; then - echo "conclusion=skipped" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection was not needed, marking as skipped" - elif [[ "$DETECTION_SUCCESS" == "true" ]]; then - echo "conclusion=success" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection passed successfully" - else - echo "conclusion=failure" >> "$GITHUB_OUTPUT" - echo "success=false" >> "$GITHUB_OUTPUT" - echo "Detection found issues" - fi - - conclusion: - needs: - - activation - - agent - - safe_outputs - if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - concurrency: - group: "gh-aw-conclusion-doc-freshness-check" - cancel-in-progress: false - outputs: - noop_message: ${{ steps.noop.outputs.noop_message }} - tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} - total_count: ${{ steps.missing_tool.outputs.total_count }} - steps: - - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 - with: - destination: ${{ runner.temp }}/gh-aw/actions - - name: Download agent output artifact - id: download-agent-output - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: agent - path: /tmp/gh-aw/ - - name: Setup agent output environment variable - if: steps.download-agent-output.outcome == 'success' - run: | - mkdir -p /tmp/gh-aw/ - find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" - - name: Process No-Op Messages - id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: "1" - GH_AW_WORKFLOW_NAME: "Documentation Freshness Check" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs'); - await main(); - - name: Record Missing Tool - id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Documentation Freshness Check" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); - await main(); - - name: Handle Agent Failure - id: handle_agent_failure - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Documentation Freshness Check" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_WORKFLOW_ID: "doc-freshness-check" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} - GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} - GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} - GH_AW_GROUP_REPORTS: "false" - GH_AW_FAILURE_REPORT_AS_ISSUE: "true" - GH_AW_TIMEOUT_MINUTES: "20" with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); - await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Documentation Freshness Check" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); await main(); safe_outputs: - needs: agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + needs: + - activation + - agent + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' runs-on: ubuntu-slim permissions: contents: read @@ -951,7 +1080,9 @@ jobs: timeout-minutes: 15 env: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/doc-freshness-check" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} GH_AW_WORKFLOW_ID: "doc-freshness-check" GH_AW_WORKFLOW_NAME: "Documentation Freshness Check" outputs: @@ -965,9 +1096,12 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -976,12 +1110,14 @@ jobs: name: agent path: /tmp/gh-aw/ - name: Setup agent output environment variable + id: setup-agent-output-env if: steps.download-agent-output.outcome == 'success' run: | mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config shell: bash run: | # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct @@ -991,25 +1127,25 @@ jobs: echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - name: Process Safe Outputs id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"max\":1},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"max\":1},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - - name: Upload Safe Output Items Manifest + - name: Upload Safe Outputs Items if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn + name: safe-outputs-items + path: /tmp/gh-aw/safe-output-items.jsonl + if-no-files-found: ignore diff --git a/.github/workflows/doc-freshness-check.md b/.github/workflows/doc-freshness-check.md index b6f232f..2a89915 100644 --- a/.github/workflows/doc-freshness-check.md +++ b/.github/workflows/doc-freshness-check.md @@ -42,7 +42,7 @@ Look for: ### 2. CLI Commands -Extract the actual CLI commands from `src/main.rs` (look at the `Commands` enum with clap derive) and compare against documented commands in `.github/copilot-instructions.md`. +Extract the actual CLI commands from `src/main.rs` (look at the `Commands` enum with clap derive) and compare against documented commands in both `.github/copilot-instructions.md` and `README.md` (CLI Reference section). Check: - All subcommands are documented @@ -51,7 +51,7 @@ Check: ### 3. Front Matter Fields -Compare the `FrontMatter` struct in `src/compile/types.rs` against the documented fields: +Compare the `FrontMatter` struct in `src/compile/types.rs` against the documented fields in both `.github/copilot-instructions.md` and `README.md` (Agent File Reference → Front Matter Fields). - Are all struct fields documented? - Do documented defaults match `#[serde(default)]` values? @@ -74,9 +74,21 @@ Compare against documented markers in `.github/copilot-instructions.md`. Check f ### 5. Safe Output Tools -Compare tools defined in `src/tools/` against what's documented: +Compare tools defined in `src/tools/` against what's documented in both `.github/copilot-instructions.md` and `README.md` (Safe Outputs section): - Are all tools documented with correct parameters? - Do configuration options match the actual implementation? +- Does `README.md` list all available safe output tools? + +### 6. README Accuracy (`README.md`) + +Check the human-facing documentation in `README.md` against the codebase: + +- **Quick Start** — do the example commands and workflows still work with the current CLI? +- **Schedule Syntax** — does the documented syntax match `src/fuzzy_schedule.rs`? +- **MCP Servers** — are all built-in MCP server names listed? Compare against the MCP handling in `src/compile/common.rs` and `src/compile/types.rs`. +- **Network Isolation** — do the listed default allowed domains match `src/allowed_hosts.rs`? +- **Safe Outputs configuration examples** — do the YAML examples match the config structs in `src/tools/`? +- **Front Matter Fields table** — do field names, types, and defaults match `src/compile/types.rs`? ## Decision Criteria diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1efa6c..2d3a667 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,8 +29,6 @@ jobs: steps: - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 id: release - with: - release-type: rust build: name: Build (Linux) diff --git a/.github/workflows/rust-pr-reviewer.lock.yml b/.github/workflows/rust-pr-reviewer.lock.yml index 4553223..f538c90 100644 --- a/.github/workflows/rust-pr-reviewer.lock.yml +++ b/.github/workflows/rust-pr-reviewer.lock.yml @@ -1,3 +1,5 @@ +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"559adca2e430a46eae51ef2f73fd9905843b66aa43330daecdc3883bd39ebbc4","compiler_version":"v0.68.1","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9"},{"repo":"actions/upload-artifact","sha":"bbbca2ddaa5d8feaa63e36b76fdaad77386f024f","version":"v7"},{"repo":"github/gh-aw-actions/setup","sha":"2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc","version":"v0.68.1"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -12,7 +14,7 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.62.0). DO NOT EDIT. +# This file was automatically generated by gh-aw (v0.68.1). DO NOT EDIT. # # To update this file, edit the corresponding .md file and run: # gh aw compile @@ -22,7 +24,19 @@ # # Reviews Rust code changes for quality, error handling, security, and project conventions # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"559adca2e430a46eae51ef2f73fd9905843b66aa43330daecdc3883bd39ebbc4","compiler_version":"v0.62.0","strict":true} +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 +# - actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 +# - github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 name: "Rust PR Reviewer" "on": @@ -51,9 +65,10 @@ jobs: activation: needs: pre_activation if: > - (needs.pre_activation.outputs.activated == 'true') && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) + needs.pre_activation.outputs.activated == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id) runs-on: ubuntu-slim permissions: + actions: read contents: read outputs: body: ${{ steps.sanitized.outputs.body }} @@ -62,42 +77,47 @@ jobs: lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} text: ${{ steps.sanitized.outputs.text }} title: ${{ steps.sanitized.outputs.title }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }} - name: Generate agentic run info id: generate_aw_info env: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_INFO_VERSION: "" - GH_AW_INFO_AGENT_VERSION: "latest" - GH_AW_INFO_CLI_VERSION: "v0.62.0" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" + GH_AW_INFO_CLI_VERSION: "v0.68.1" GH_AW_INFO_WORKFLOW_NAME: "Rust PR Reviewer" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","rust"]' GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.24.3" + GH_AW_INFO_AWF_VERSION: "v0.25.18" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Validate COPILOT_GITHUB_TOKEN secret id: validate-secret - run: ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders @@ -109,31 +129,43 @@ jobs: .agents sparse-checkout-cone-mode: true fetch-depth: 1 - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_WORKFLOW_FILE: "rust-pr-reviewer.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_COMPILED_VERSION: "v0.68.1" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); - name: Compute current body text id: sanitized - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_ALLOWED_BOTS: copilot[bot] + GH_AW_ALLOWED_BOTS: "copilot[bot]" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/compute_text.cjs'); await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} @@ -142,19 +174,20 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec run: | - bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_1c5c9d98297e2518_EOF' - GH_AW_PROMPT_EOF + GH_AW_PROMPT_1c5c9d98297e2518_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_1c5c9d98297e2518_EOF' - Tools: add_comment, missing_tool, missing_data, noop + Tools: add_comment(max:3), missing_tool, missing_data, noop The following GitHub context information is available for this workflow: @@ -184,27 +217,25 @@ jobs: {{/if}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_1c5c9d98297e2518_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_1c5c9d98297e2518_EOF' - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/rust-pr-reviewer.md}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_1c5c9d98297e2518_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); await main(); - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_GITHUB_ACTOR: ${{ github.actor }} @@ -219,7 +250,7 @@ jobs: with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); @@ -241,19 +272,23 @@ jobs: - name: Validate prompt placeholders env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" - name: Upload activation artifact if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: activation path: | /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/github_rate_limits.jsonl + if-no-files-found: ignore retention-days: 1 agent: @@ -272,68 +307,73 @@ jobs: GH_AW_WORKFLOW_ID_SANITIZED: rustprreviewer outputs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} - detection_success: ${{ steps.detection_conclusion.outputs.success }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} has_patch: ${{ steps.collect_output.outputs.has_patch }} inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} model: ${{ needs.activation.outputs.model }} output: ${{ steps.collect_output.outputs.output }} output_types: ${{ steps.collect_output.outputs.output_types }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Set runtime paths + id: set-runtime-paths run: | - echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_ENV" + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Create gh-aw temp directory - run: bash ${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise - run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" env: GH_TOKEN: ${{ github.token }} - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Checkout PR branch id: checkout-pr if: | - (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 env: GH_HOST: github.com - name: Install AWF binary - run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.3 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 env: GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} @@ -342,106 +382,126 @@ jobs: const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.3 ghcr.io/github/gh-aw-firewall/squid:0.24.3 ghcr.io/github/gh-aw-mcpg:v0.1.19 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 ghcr.io/github/gh-aw-mcpg:v0.2.17 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config run: | - mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"add_comment":{"max":3},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_f1e47fb462a8c637_EOF' + {"add_comment":{"max":3},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_f1e47fb462a8c637_EOF - name: Write Safe Outputs Tools - run: | - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF' - { - "description_suffixes": { - "add_comment": " CONSTRAINTS: Maximum 3 comment(s) can be added." - }, - "repo_params": {}, - "dynamic_tools": [] - } - GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "add_comment": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "item_number": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "add_comment": " CONSTRAINTS: Maximum 3 comment(s) can be added." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + } } - } - }, - "missing_data": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "context": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "data_type": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "reason": { - "type": "string", - "sanitize": true, - "maxLength": 256 + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } } } } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); - name: Generate Safe Outputs MCP Server Config id: safe-outputs-config run: | @@ -464,6 +524,7 @@ jobs: id: safe-outputs-start env: DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json @@ -472,18 +533,19 @@ jobs: run: | # Environment variables are set above to prevent template injection export DEBUG + export GH_AW_SAFE_OUTPUTS export GH_AW_SAFE_OUTPUTS_PORT export GH_AW_SAFE_OUTPUTS_API_KEY export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" - name: Start MCP Gateway id: start-mcp-gateway env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} @@ -505,10 +567,10 @@ jobs: export DEBUG="*" export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.19' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.17' mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_411e0eb953087253_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh" { "mcpServers": { "github": { @@ -549,7 +611,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_EOF + GH_AW_MCP_CONFIG_411e0eb953087253_EOF - name: Download activation artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -557,7 +619,7 @@ jobs: path: /tmp/gh-aw - name: Clean git credentials continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): @@ -565,9 +627,10 @@ jobs: run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} @@ -575,8 +638,8 @@ jobs: GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_VERSION: v0.62.0 + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.68.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_HEAD_REF: ${{ github.head_ref }} @@ -594,36 +657,24 @@ jobs: id: detect-inference-error if: always() continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh" - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Copy Copilot session state files to logs if: always() continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" - name: Stop MCP Gateway if: always() continue-on-error: true @@ -632,14 +683,14 @@ jobs: MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} run: | - bash ${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" - name: Redact secrets in logs if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); await main(); env: @@ -650,45 +701,48 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Append agent step summary if: always() - run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" - name: Copy Safe Outputs if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} run: | mkdir -p /tmp/gh-aw cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true - name: Ingest agent output id: collect_output if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); await main(); - name: Parse agent logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); await main(); - name: Parse MCP Gateway logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); await main(); - name: Print firewall logs @@ -706,10 +760,26 @@ jobs: else echo 'AWF binary not installed, skipping firewall log summary' fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: agent path: | @@ -717,19 +787,191 @@ jobs: /tmp/gh-aw/sandbox/agent/logs/ /tmp/gh-aw/redacted-urls.log /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent-stdio.log /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/safeoutputs.jsonl /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle if-no-files-found: ignore - # --- Threat Detection (inline) --- + - name: Upload firewall audit logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: firewall-audit-logs + path: | + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + concurrency: + group: "gh-aw-conclusion-rust-pr-reviewer" + cancel-in-progress: false + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "rust-pr-reviewer" + GH_AW_ENGINE_ID: "copilot" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + detection: + needs: + - activation + - agent + if: > + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 - name: Check if detection needed id: detection_guard if: always() env: - OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} run: | if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then echo "run_detection=true" >> "$GITHUB_OUTPUT" @@ -753,19 +995,22 @@ jobs: for f in /tmp/gh-aw/aw-*.patch; do [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done echo "Prepared threat detection files:" ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: WORKFLOW_NAME: "Rust PR Reviewer" WORKFLOW_DESCRIPTION: "Reviews Rust code changes for quality, error handling, security, and project conventions" - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); await main(); - name: Ensure threat-detection directory and log @@ -773,31 +1018,31 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 - name: Execute GitHub Copilot CLI if: always() && steps.detection_guard.outputs.run_detection == 'true' id: detection_agentic_execution # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) timeout-minutes: 20 run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,telemetry.enterprise.githubcopilot.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_VERSION: v0.62.0 + GH_AW_VERSION: v0.68.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_HEAD_REF: ${{ github.head_ref }} @@ -810,178 +1055,60 @@ jobs: GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_detection_results - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - name: Upload threat detection log if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: detection path: /tmp/gh-aw/threat-detection/detection.log if-no-files-found: ignore - - name: Set detection conclusion + - name: Parse and conclude threat detection id: detection_conclusion if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} - DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} - run: | - if [[ "$RUN_DETECTION" != "true" ]]; then - echo "conclusion=skipped" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection was not needed, marking as skipped" - elif [[ "$DETECTION_SUCCESS" == "true" ]]; then - echo "conclusion=success" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection passed successfully" - else - echo "conclusion=failure" >> "$GITHUB_OUTPUT" - echo "success=false" >> "$GITHUB_OUTPUT" - echo "Detection found issues" - fi - - conclusion: - needs: - - activation - - agent - - safe_outputs - if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) - runs-on: ubuntu-slim - permissions: - contents: read - discussions: write - issues: write - pull-requests: write - concurrency: - group: "gh-aw-conclusion-rust-pr-reviewer" - cancel-in-progress: false - outputs: - noop_message: ${{ steps.noop.outputs.noop_message }} - tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} - total_count: ${{ steps.missing_tool.outputs.total_count }} - steps: - - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 - with: - destination: ${{ runner.temp }}/gh-aw/actions - - name: Download agent output artifact - id: download-agent-output - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: agent - path: /tmp/gh-aw/ - - name: Setup agent output environment variable - if: steps.download-agent-output.outcome == 'success' - run: | - mkdir -p /tmp/gh-aw/ - find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" - - name: Process No-Op Messages - id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: "1" - GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs'); - await main(); - - name: Record Missing Tool - id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); - await main(); - - name: Handle Agent Failure - id: handle_agent_failure - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_WORKFLOW_ID: "rust-pr-reviewer" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} - GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} - GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} - GH_AW_GROUP_REPORTS: "false" - GH_AW_FAILURE_REPORT_AS_ISSUE: "true" - GH_AW_TIMEOUT_MINUTES: "20" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); - await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); await main(); pre_activation: - if: (github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id runs-on: ubuntu-slim outputs: activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} matched_command: '' + setup-trace-id: ${{ steps.setup.outputs.trace-id }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} - name: Check team membership for workflow id: check_membership - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_REQUIRED_ROLES: admin,maintainer,write - GH_AW_ALLOWED_BOTS: copilot[bot] + GH_AW_REQUIRED_ROLES: "admin,maintainer,write" + GH_AW_ALLOWED_BOTS: "copilot[bot]" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs'); await main(); safe_outputs: - needs: agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + needs: + - activation + - agent + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' runs-on: ubuntu-slim permissions: contents: read @@ -991,7 +1118,9 @@ jobs: timeout-minutes: 15 env: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/rust-pr-reviewer" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} GH_AW_WORKFLOW_ID: "rust-pr-reviewer" GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" outputs: @@ -1005,9 +1134,12 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1016,12 +1148,14 @@ jobs: name: agent path: /tmp/gh-aw/ - name: Setup agent output environment variable + id: setup-agent-output-env if: steps.download-agent-output.outcome == 'success' run: | mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config shell: bash run: | # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct @@ -1031,25 +1165,25 @@ jobs: echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - name: Process Safe Outputs id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":3},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":3},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - - name: Upload Safe Output Items Manifest + - name: Upload Safe Outputs Items if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn + name: safe-outputs-items + path: /tmp/gh-aw/safe-output-items.jsonl + if-no-files-found: ignore diff --git a/.github/workflows/rust-review-command.lock.yml b/.github/workflows/rust-review-command.lock.yml index 30ed669..a8197d6 100644 --- a/.github/workflows/rust-review-command.lock.yml +++ b/.github/workflows/rust-review-command.lock.yml @@ -1,3 +1,5 @@ +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"2452066dc3ad07be51be2fd546eebdb72a492d04d17d53aeda509c6bc9a174b9","compiler_version":"v0.68.1","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9"},{"repo":"actions/upload-artifact","sha":"bbbca2ddaa5d8feaa63e36b76fdaad77386f024f","version":"v7"},{"repo":"github/gh-aw-actions/setup","sha":"2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc","version":"v0.68.1"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -12,7 +14,7 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.62.0). DO NOT EDIT. +# This file was automatically generated by gh-aw (v0.68.1). DO NOT EDIT. # # To update this file, edit the corresponding .md file and run: # gh aw compile @@ -22,7 +24,19 @@ # # On-demand Rust code review triggered by /rust-review command on PRs # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"2452066dc3ad07be51be2fd546eebdb72a492d04d17d53aeda509c6bc9a174b9","compiler_version":"v0.62.0","strict":true} +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 +# - actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 +# - github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 name: "Rust PR Reviewer" "on": @@ -48,14 +62,10 @@ run-name: "Rust PR Reviewer" jobs: activation: needs: pre_activation - if: > - (needs.pre_activation.outputs.activated == 'true') && (((github.event_name == 'pull_request' || github.event_name == 'issue_comment') && - ((github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/rust-review ')) || - (github.event.comment.body == '/rust-review')) && (github.event.issue.pull_request != null)) || (github.event_name == 'pull_request') && - ((startsWith(github.event.pull_request.body, '/rust-review ')) || (github.event.pull_request.body == '/rust-review')))) || - (!(github.event_name == 'pull_request' || github.event_name == 'issue_comment'))) + if: "needs.pre_activation.outputs.activated == 'true' && ((github.event_name == 'pull_request' || github.event_name == 'issue_comment') && (github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/rust-review ') || startsWith(github.event.comment.body, '/rust-review\n') || github.event.comment.body == '/rust-review') && github.event.issue.pull_request != null || github.event_name == 'pull_request' && (startsWith(github.event.pull_request.body, '/rust-review ') || startsWith(github.event.pull_request.body, '/rust-review\n') || github.event.pull_request.body == '/rust-review')) || (!(github.event_name == 'pull_request')) && (!(github.event_name == 'issue_comment')))" runs-on: ubuntu-slim permissions: + actions: read contents: read discussions: write issues: write @@ -68,43 +78,61 @@ jobs: lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} slash_command: ${{ needs.pre_activation.outputs.matched_command }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} text: ${{ steps.sanitized.outputs.text }} title: ${{ steps.sanitized.outputs.title }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }} - name: Generate agentic run info id: generate_aw_info env: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_INFO_VERSION: "" - GH_AW_INFO_AGENT_VERSION: "latest" - GH_AW_INFO_CLI_VERSION: "v0.62.0" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" + GH_AW_INFO_CLI_VERSION: "v0.68.1" GH_AW_INFO_WORKFLOW_NAME: "Rust PR Reviewer" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","rust"]' GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.24.3" + GH_AW_INFO_AWF_VERSION: "v0.25.18" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); + - name: Add eyes reaction for immediate feedback + id: react + if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_REACTION: "eyes" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/add_reaction.cjs'); + await main(); - name: Validate COPILOT_GITHUB_TOKEN secret id: validate-secret - run: ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders @@ -116,56 +144,55 @@ jobs: .agents sparse-checkout-cone-mode: true fetch-depth: 1 - - name: Add eyes reaction for immediate feedback - id: react - if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.id == github.repository_id) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_REACTION: "eyes" + GH_AW_WORKFLOW_FILE: "rust-review-command.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" with: - github-token: ${{ secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/add_reaction.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_WORKFLOW_FILE: "rust-review-command.lock.yml" + GH_AW_COMPILED_VERSION: "v0.68.1" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); await main(); - name: Compute current body text id: sanitized - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_ALLOWED_BOTS: copilot[bot] + GH_AW_ALLOWED_BOTS: "copilot[bot]" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/compute_text.cjs'); await main(); - name: Add comment with workflow run link id: add-comment - if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.id == github.repository_id) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/add_workflow_run_comment.cjs'); await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} @@ -176,19 +203,20 @@ jobs: GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} GH_AW_IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || '' }} GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT: ${{ steps.sanitized.outputs.text }} + # poutine:ignore untrusted_checkout_exec run: | - bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_ec1e8615035f8f58_EOF' - GH_AW_PROMPT_EOF + GH_AW_PROMPT_ec1e8615035f8f58_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_ec1e8615035f8f58_EOF' - Tools: add_comment, missing_tool, missing_data, noop + Tools: add_comment(max:3), missing_tool, missing_data, noop The following GitHub context information is available for this workflow: @@ -218,31 +246,29 @@ jobs: {{/if}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_ec1e8615035f8f58_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then cat "${RUNNER_TEMP}/gh-aw/prompts/pr_context_prompt.md" fi - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_ec1e8615035f8f58_EOF' - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/rust-review-command.md}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_ec1e8615035f8f58_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT: ${{ steps.sanitized.outputs.text }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); await main(); - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_GITHUB_ACTOR: ${{ github.actor }} @@ -260,7 +286,7 @@ jobs: with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); @@ -285,19 +311,23 @@ jobs: - name: Validate prompt placeholders env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" - name: Upload activation artifact if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: activation path: | /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/github_rate_limits.jsonl + if-no-files-found: ignore retention-days: 1 agent: @@ -316,68 +346,73 @@ jobs: GH_AW_WORKFLOW_ID_SANITIZED: rustreviewcommand outputs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} - detection_success: ${{ steps.detection_conclusion.outputs.success }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} has_patch: ${{ steps.collect_output.outputs.has_patch }} inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} model: ${{ needs.activation.outputs.model }} output: ${{ steps.collect_output.outputs.output }} output_types: ${{ steps.collect_output.outputs.output_types }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Set runtime paths + id: set-runtime-paths run: | - echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_ENV" + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Create gh-aw temp directory - run: bash ${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise - run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" env: GH_TOKEN: ${{ github.token }} - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Checkout PR branch id: checkout-pr if: | - (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 env: GH_HOST: github.com - name: Install AWF binary - run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.3 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 env: GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} @@ -386,106 +421,126 @@ jobs: const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.3 ghcr.io/github/gh-aw-firewall/squid:0.24.3 ghcr.io/github/gh-aw-mcpg:v0.1.19 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 ghcr.io/github/gh-aw-mcpg:v0.2.17 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config run: | - mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"add_comment":{"max":3},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_b4d6deb4f953bb29_EOF' + {"add_comment":{"max":3},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_b4d6deb4f953bb29_EOF - name: Write Safe Outputs Tools - run: | - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF' - { - "description_suffixes": { - "add_comment": " CONSTRAINTS: Maximum 3 comment(s) can be added." - }, - "repo_params": {}, - "dynamic_tools": [] - } - GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "add_comment": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "item_number": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "add_comment": " CONSTRAINTS: Maximum 3 comment(s) can be added." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + } } - } - }, - "missing_data": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "context": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "data_type": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "reason": { - "type": "string", - "sanitize": true, - "maxLength": 256 + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } } } } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); - name: Generate Safe Outputs MCP Server Config id: safe-outputs-config run: | @@ -508,6 +563,7 @@ jobs: id: safe-outputs-start env: DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json @@ -516,18 +572,19 @@ jobs: run: | # Environment variables are set above to prevent template injection export DEBUG + export GH_AW_SAFE_OUTPUTS export GH_AW_SAFE_OUTPUTS_PORT export GH_AW_SAFE_OUTPUTS_API_KEY export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" - name: Start MCP Gateway id: start-mcp-gateway env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} @@ -549,10 +606,10 @@ jobs: export DEBUG="*" export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.19' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.17' mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_6d6927d2964239b6_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh" { "mcpServers": { "github": { @@ -593,7 +650,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_EOF + GH_AW_MCP_CONFIG_6d6927d2964239b6_EOF - name: Download activation artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -601,7 +658,7 @@ jobs: path: /tmp/gh-aw - name: Clean git credentials continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): @@ -609,9 +666,10 @@ jobs: run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} @@ -619,8 +677,8 @@ jobs: GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_VERSION: v0.62.0 + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.68.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_HEAD_REF: ${{ github.head_ref }} @@ -638,36 +696,24 @@ jobs: id: detect-inference-error if: always() continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh" - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Copy Copilot session state files to logs if: always() continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" - name: Stop MCP Gateway if: always() continue-on-error: true @@ -676,14 +722,14 @@ jobs: MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} run: | - bash ${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" - name: Redact secrets in logs if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); await main(); env: @@ -694,18 +740,20 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Append agent step summary if: always() - run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" - name: Copy Safe Outputs if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} run: | mkdir -p /tmp/gh-aw cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true - name: Ingest agent output id: collect_output if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} @@ -713,27 +761,28 @@ jobs: with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); await main(); - name: Parse agent logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); await main(); - name: Parse MCP Gateway logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); await main(); - name: Print firewall logs @@ -751,10 +800,26 @@ jobs: else echo 'AWF binary not installed, skipping firewall log summary' fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: agent path: | @@ -762,143 +827,35 @@ jobs: /tmp/gh-aw/sandbox/agent/logs/ /tmp/gh-aw/redacted-urls.log /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent-stdio.log /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/safeoutputs.jsonl /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle if-no-files-found: ignore - # --- Threat Detection (inline) --- - - name: Check if detection needed - id: detection_guard + - name: Upload firewall audit logs if: always() - env: - OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - run: | - if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then - echo "run_detection=true" >> "$GITHUB_OUTPUT" - echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" - else - echo "run_detection=false" >> "$GITHUB_OUTPUT" - echo "Detection skipped: no agent outputs or patches to analyze" - fi - - name: Clear MCP configuration for detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - rm -f /tmp/gh-aw/mcp-config/mcp-servers.json - rm -f /home/runner/.copilot/mcp-config.json - rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" - - name: Prepare threat detection files - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - - name: Setup threat detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_NAME: "Rust PR Reviewer" - WORKFLOW_DESCRIPTION: "On-demand Rust code review triggered by /rust-review command on PRs" - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); - await main(); - - name: Ensure threat-detection directory and log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection - touch /tmp/gh-aw/threat-detection/detection.log - - name: Execute GitHub Copilot CLI - if: always() && steps.detection_guard.outputs.run_detection == 'true' - id: detection_agentic_execution - # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) - timeout-minutes: 20 - run: | - set -o pipefail - touch /tmp/gh-aw/agent-step-summary.md - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} - GH_AW_PHASE: detection - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_VERSION: v0.62.0 - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_AW: true - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md - GITHUB_WORKSPACE: ${{ github.workspace }} - GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_AUTHOR_NAME: github-actions[bot] - GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: github-actions[bot] - XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_detection_results - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - - name: Upload threat detection log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: detection - path: /tmp/gh-aw/threat-detection/detection.log + name: firewall-audit-logs + path: | + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ if-no-files-found: ignore - - name: Set detection conclusion - id: detection_conclusion - if: always() - env: - RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} - DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} - run: | - if [[ "$RUN_DETECTION" != "true" ]]; then - echo "conclusion=skipped" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection was not needed, marking as skipped" - elif [[ "$DETECTION_SUCCESS" == "true" ]]; then - echo "conclusion=success" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection passed successfully" - else - echo "conclusion=failure" >> "$GITHUB_OUTPUT" - echo "success=false" >> "$GITHUB_OUTPUT" - echo "Detection found issues" - fi conclusion: needs: - activation - agent + - detection - safe_outputs - if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') runs-on: ubuntu-slim permissions: contents: read @@ -909,14 +866,18 @@ jobs: group: "gh-aw-conclusion-rust-review-command" cancel-in-progress: false outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} noop_message: ${{ steps.noop.outputs.noop_message }} tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -925,52 +886,73 @@ jobs: name: agent path: /tmp/gh-aw/ - name: Setup agent output environment variable + id: setup-agent-output-env if: steps.download-agent-output.outcome == 'success' run: | mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Process No-Op Messages id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_NOOP_MAX: "1" GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); await main(); - - name: Record Missing Tool + - name: Record missing tool id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); await main(); - - name: Handle Agent Failure + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure id: handle_agent_failure if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_WORKFLOW_ID: "rust-review-command" + GH_AW_ENGINE_ID: "copilot" GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} GH_AW_GROUP_REPORTS: "false" GH_AW_FAILURE_REPORT_AS_ISSUE: "true" GH_AW_TIMEOUT_MINUTES: "20" @@ -978,88 +960,224 @@ jobs: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); - await main(); - name: Update reaction comment with completion status id: conclusion - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_DETECTION_CONCLUSION: ${{ needs.agent.outputs.detection_conclusion }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs'); await main(); - pre_activation: + detection: + needs: + - activation + - agent if: > - ((github.event_name == 'pull_request' || github.event_name == 'issue_comment') && ((github.event_name == 'issue_comment') && - (((startsWith(github.event.comment.body, '/rust-review ')) || (github.event.comment.body == '/rust-review')) && - (github.event.issue.pull_request != null)) || (github.event_name == 'pull_request') && ((startsWith(github.event.pull_request.body, '/rust-review ')) || - (github.event.pull_request.body == '/rust-review')))) || (!(github.event_name == 'pull_request' || github.event_name == 'issue_comment')) + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + WORKFLOW_NAME: "Rust PR Reviewer" + WORKFLOW_DESCRIPTION: "On-demand Rust code review triggered by /rust-review command on PRs" + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) + # shellcheck disable=SC1003 + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,telemetry.enterprise.githubcopilot.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: v0.68.1 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: detection + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Parse and conclude threat detection + id: detection_conclusion + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + + pre_activation: + if: "(github.event_name == 'pull_request' || github.event_name == 'issue_comment') && (github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/rust-review ') || startsWith(github.event.comment.body, '/rust-review\n') || github.event.comment.body == '/rust-review') && github.event.issue.pull_request != null || github.event_name == 'pull_request' && (startsWith(github.event.pull_request.body, '/rust-review ') || startsWith(github.event.pull_request.body, '/rust-review\n') || github.event.pull_request.body == '/rust-review')) || (!(github.event_name == 'pull_request')) && (!(github.event_name == 'issue_comment'))" runs-on: ubuntu-slim outputs: - activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_command_position.outputs.command_position_ok == 'true') }} + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_command_position.outputs.command_position_ok == 'true' }} matched_command: ${{ steps.check_command_position.outputs.matched_command }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} - name: Check team membership for command workflow id: check_membership - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_REQUIRED_ROLES: admin,maintainer,write - GH_AW_ALLOWED_BOTS: copilot[bot] + GH_AW_REQUIRED_ROLES: "admin,maintainer,write" + GH_AW_ALLOWED_BOTS: "copilot[bot]" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs'); await main(); - name: Check command position id: check_command_position - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_COMMANDS: "[\"rust-review\"]" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_command_position.cjs'); await main(); safe_outputs: - needs: agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + needs: + - activation + - agent + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' runs-on: ubuntu-slim permissions: contents: read @@ -1069,7 +1187,9 @@ jobs: timeout-minutes: 15 env: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/rust-review-command" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} GH_AW_WORKFLOW_ID: "rust-review-command" GH_AW_WORKFLOW_NAME: "Rust PR Reviewer" outputs: @@ -1083,9 +1203,12 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1094,12 +1217,14 @@ jobs: name: agent path: /tmp/gh-aw/ - name: Setup agent output environment variable + id: setup-agent-output-env if: steps.download-agent-output.outcome == 'success' run: | mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config shell: bash run: | # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct @@ -1109,25 +1234,25 @@ jobs: echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - name: Process Safe Outputs id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":3},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":3},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - - name: Upload Safe Output Items Manifest + - name: Upload Safe Outputs Items if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn + name: safe-outputs-items + path: /tmp/gh-aw/safe-output-items.jsonl + if-no-files-found: ignore diff --git a/.github/workflows/test-gap-finder.lock.yml b/.github/workflows/test-gap-finder.lock.yml index 1309a8a..69fe457 100644 --- a/.github/workflows/test-gap-finder.lock.yml +++ b/.github/workflows/test-gap-finder.lock.yml @@ -1,3 +1,5 @@ +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"2bbb20c5f6f94b20874d8b6efc8f1be6b7e8ef37bd931767d1d1071b36270753","compiler_version":"v0.68.1","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"668228422ae6a00e4ad889ee87cd7109ec5666a7","version":"v5.0.4"},{"repo":"actions/cache/save","sha":"668228422ae6a00e4ad889ee87cd7109ec5666a7","version":"v5.0.4"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9"},{"repo":"actions/upload-artifact","sha":"bbbca2ddaa5d8feaa63e36b76fdaad77386f024f","version":"v7"},{"repo":"github/gh-aw-actions/setup","sha":"2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc","version":"v0.68.1"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -12,7 +14,7 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.62.0). DO NOT EDIT. +# This file was automatically generated by gh-aw (v0.68.1). DO NOT EDIT. # # To update this file, edit the corresponding .md file and run: # gh aw compile @@ -22,14 +24,34 @@ # # Analyzes test coverage and suggests missing test cases for untested compiler paths # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"2bbb20c5f6f94b20874d8b6efc8f1be6b7e8ef37bd931767d1d1071b36270753","compiler_version":"v0.62.0","strict":true} +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 +# - actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 +# - actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 +# - github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 name: "Test Gap Finder" "on": schedule: - - cron: "36 16 * * 1-5" + - cron: "36 11 * * 1-5" # Friendly format: daily on weekdays (scattered) workflow_dispatch: + inputs: + aw_context: + default: "" + description: Agent caller context (used internally by Agentic Workflows). + required: false + type: string permissions: {} @@ -42,6 +64,7 @@ jobs: activation: runs-on: ubuntu-slim permissions: + actions: read contents: read outputs: comment_id: "" @@ -49,40 +72,44 @@ jobs: lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} - name: Generate agentic run info id: generate_aw_info env: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_INFO_VERSION: "" - GH_AW_INFO_AGENT_VERSION: "latest" - GH_AW_INFO_CLI_VERSION: "v0.62.0" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" + GH_AW_INFO_CLI_VERSION: "v0.68.1" GH_AW_INFO_WORKFLOW_NAME: "Test Gap Finder" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","rust"]' GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.24.3" + GH_AW_INFO_AWF_VERSION: "v0.25.18" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Validate COPILOT_GITHUB_TOKEN secret id: validate-secret - run: ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders @@ -94,20 +121,32 @@ jobs: .agents sparse-checkout-cone-mode: true fetch-depth: 1 - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_WORKFLOW_FILE: "test-gap-finder.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_COMPILED_VERSION: "v0.68.1" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} @@ -116,18 +155,19 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec run: | - bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_f01542e0121381da_EOF' - GH_AW_PROMPT_EOF + GH_AW_PROMPT_f01542e0121381da_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_f01542e0121381da_EOF' Tools: create_issue, missing_tool, missing_data, noop @@ -159,27 +199,25 @@ jobs: {{/if}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_f01542e0121381da_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_f01542e0121381da_EOF' - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/test-gap-finder.md}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_f01542e0121381da_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); await main(); - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_ALLOWED_EXTENSIONS: '' @@ -196,7 +234,7 @@ jobs: with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); @@ -220,19 +258,23 @@ jobs: - name: Validate prompt placeholders env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" - name: Upload activation artifact if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: activation path: | /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/github_rate_limits.jsonl + if-no-files-found: ignore retention-days: 1 agent: @@ -253,78 +295,88 @@ jobs: GH_AW_WORKFLOW_ID_SANITIZED: testgapfinder outputs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} - detection_success: ${{ steps.detection_conclusion.outputs.success }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} has_patch: ${{ steps.collect_output.outputs.has_patch }} inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} model: ${{ needs.activation.outputs.model }} output: ${{ steps.collect_output.outputs.output }} output_types: ${{ steps.collect_output.outputs.output_types }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Set runtime paths + id: set-runtime-paths run: | - echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_ENV" + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Create gh-aw temp directory - run: bash ${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise - run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" env: GH_TOKEN: ${{ github.token }} # Cache memory file share configuration from frontmatter processed below - name: Create cache-memory directory - run: bash ${RUNNER_TEMP}/gh-aw/actions/create_cache_memory_dir.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_cache_memory_dir.sh" - name: Restore cache-memory file share data - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: - key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} + key: memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} path: /tmp/gh-aw/cache-memory restore-keys: | - memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}- + memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}- + - name: Setup cache-memory git repository + env: + GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory + GH_AW_MIN_INTEGRITY: none + run: bash "${RUNNER_TEMP}/gh-aw/actions/setup_cache_memory_git.sh" - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Checkout PR branch id: checkout-pr if: | - (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 env: GH_HOST: github.com - name: Install AWF binary - run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.3 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 env: GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} @@ -333,121 +385,141 @@ jobs: const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.3 ghcr.io/github/gh-aw-firewall/squid:0.24.3 ghcr.io/github/gh-aw-mcpg:v0.1.19 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 ghcr.io/github/gh-aw-mcpg:v0.2.17 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config run: | - mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"create_issue":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_f073ed8cac788bb9_EOF' + {"create_issue":{"max":1},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_f073ed8cac788bb9_EOF - name: Write Safe Outputs Tools - run: | - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF' - { - "description_suffixes": { - "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created." - }, - "repo_params": {}, - "dynamic_tools": [] - } - GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "create_issue": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "parent": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "temporary_id": { - "type": "string" - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } } - } - }, - "missing_data": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "context": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "data_type": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "reason": { - "type": "string", - "sanitize": true, - "maxLength": 256 + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } } } } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); - name: Generate Safe Outputs MCP Server Config id: safe-outputs-config run: | @@ -470,6 +542,7 @@ jobs: id: safe-outputs-start env: DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json @@ -478,18 +551,19 @@ jobs: run: | # Environment variables are set above to prevent template injection export DEBUG + export GH_AW_SAFE_OUTPUTS export GH_AW_SAFE_OUTPUTS_PORT export GH_AW_SAFE_OUTPUTS_API_KEY export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" - name: Start MCP Gateway id: start-mcp-gateway env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} @@ -511,10 +585,10 @@ jobs: export DEBUG="*" export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.19' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.17' mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_d04194c12e1e2988_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh" { "mcpServers": { "github": { @@ -555,7 +629,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_EOF + GH_AW_MCP_CONFIG_d04194c12e1e2988_EOF - name: Download activation artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -563,7 +637,7 @@ jobs: path: /tmp/gh-aw - name: Clean git credentials continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): @@ -571,9 +645,10 @@ jobs: run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} @@ -581,8 +656,8 @@ jobs: GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_VERSION: v0.62.0 + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.68.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_HEAD_REF: ${{ github.head_ref }} @@ -600,36 +675,24 @@ jobs: id: detect-inference-error if: always() continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh" - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Copy Copilot session state files to logs if: always() continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" - name: Stop MCP Gateway if: always() continue-on-error: true @@ -638,14 +701,14 @@ jobs: MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} run: | - bash ${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" - name: Redact secrets in logs if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); await main(); env: @@ -656,45 +719,48 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Append agent step summary if: always() - run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" - name: Copy Safe Outputs if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} run: | mkdir -p /tmp/gh-aw cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true - name: Ingest agent output id: collect_output if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); await main(); - name: Parse agent logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); await main(); - name: Parse MCP Gateway logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); await main(); - name: Print firewall logs @@ -712,8 +778,29 @@ jobs: else echo 'AWF binary not installed, skipping firewall log summary' fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - name: Commit cache-memory changes + if: always() + env: + GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory + run: bash "${RUNNER_TEMP}/gh-aw/actions/commit_cache_memory_git.sh" - name: Upload cache-memory data as artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 if: always() with: name: cache-memory @@ -721,7 +808,7 @@ jobs: - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: agent path: | @@ -729,19 +816,190 @@ jobs: /tmp/gh-aw/sandbox/agent/logs/ /tmp/gh-aw/redacted-urls.log /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent-stdio.log /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/safeoutputs.jsonl /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle if-no-files-found: ignore - # --- Threat Detection (inline) --- + - name: Upload firewall audit logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: firewall-audit-logs + path: | + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + - update_cache_memory + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + concurrency: + group: "gh-aw-conclusion-test-gap-finder" + cancel-in-progress: false + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Test Gap Finder" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Test Gap Finder" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Test Gap Finder" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Test Gap Finder" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "test-gap-finder" + GH_AW_ENGINE_ID: "copilot" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + detection: + needs: + - activation + - agent + if: > + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 - name: Check if detection needed id: detection_guard if: always() env: - OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} run: | if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then echo "run_detection=true" >> "$GITHUB_OUTPUT" @@ -765,19 +1023,22 @@ jobs: for f in /tmp/gh-aw/aw-*.patch; do [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done echo "Prepared threat detection files:" ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: WORKFLOW_NAME: "Test Gap Finder" WORKFLOW_DESCRIPTION: "Analyzes test coverage and suggests missing test cases for untested compiler paths" - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); await main(); - name: Ensure threat-detection directory and log @@ -785,31 +1046,31 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 - name: Execute GitHub Copilot CLI if: always() && steps.detection_guard.outputs.run_detection == 'true' id: detection_agentic_execution # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) timeout-minutes: 20 run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,telemetry.enterprise.githubcopilot.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_VERSION: v0.62.0 + GH_AW_VERSION: v0.68.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_HEAD_REF: ${{ github.head_ref }} @@ -822,152 +1083,32 @@ jobs: GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_detection_results - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - name: Upload threat detection log if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: detection path: /tmp/gh-aw/threat-detection/detection.log if-no-files-found: ignore - - name: Set detection conclusion + - name: Parse and conclude threat detection id: detection_conclusion if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} - DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} - run: | - if [[ "$RUN_DETECTION" != "true" ]]; then - echo "conclusion=skipped" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection was not needed, marking as skipped" - elif [[ "$DETECTION_SUCCESS" == "true" ]]; then - echo "conclusion=success" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection passed successfully" - else - echo "conclusion=failure" >> "$GITHUB_OUTPUT" - echo "success=false" >> "$GITHUB_OUTPUT" - echo "Detection found issues" - fi - - conclusion: - needs: - - activation - - agent - - safe_outputs - - update_cache_memory - if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - concurrency: - group: "gh-aw-conclusion-test-gap-finder" - cancel-in-progress: false - outputs: - noop_message: ${{ steps.noop.outputs.noop_message }} - tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} - total_count: ${{ steps.missing_tool.outputs.total_count }} - steps: - - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 - with: - destination: ${{ runner.temp }}/gh-aw/actions - - name: Download agent output artifact - id: download-agent-output - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: agent - path: /tmp/gh-aw/ - - name: Setup agent output environment variable - if: steps.download-agent-output.outcome == 'success' - run: | - mkdir -p /tmp/gh-aw/ - find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" - - name: Process No-Op Messages - id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: "1" - GH_AW_WORKFLOW_NAME: "Test Gap Finder" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs'); - await main(); - - name: Record Missing Tool - id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Test Gap Finder" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); - await main(); - - name: Handle Agent Failure - id: handle_agent_failure - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Test Gap Finder" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_WORKFLOW_ID: "test-gap-finder" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} - GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} - GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} - GH_AW_GROUP_REPORTS: "false" - GH_AW_FAILURE_REPORT_AS_ISSUE: "true" - GH_AW_TIMEOUT_MINUTES: "20" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); - await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Test Gap Finder" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); await main(); safe_outputs: - needs: agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + needs: + - activation + - agent + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' runs-on: ubuntu-slim permissions: contents: read @@ -975,7 +1116,9 @@ jobs: timeout-minutes: 15 env: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/test-gap-finder" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} GH_AW_WORKFLOW_ID: "test-gap-finder" GH_AW_WORKFLOW_NAME: "Test Gap Finder" outputs: @@ -989,9 +1132,12 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1000,12 +1146,14 @@ jobs: name: agent path: /tmp/gh-aw/ - name: Setup agent output environment variable + id: setup-agent-output-env if: steps.download-agent-output.outcome == 'success' run: | mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config shell: bash run: | # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct @@ -1015,40 +1163,48 @@ jobs: echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - name: Process Safe Outputs id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,index.crates.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sh.rustup.rs,static.crates.io,static.rust-lang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"max\":1},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"max\":1},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - - name: Upload Safe Output Items Manifest + - name: Upload Safe Outputs Items if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn + name: safe-outputs-items + path: /tmp/gh-aw/safe-output-items.jsonl + if-no-files-found: ignore update_cache_memory: - needs: agent - if: always() && needs.agent.outputs.detection_success == 'true' - runs-on: ubuntu-latest + needs: + - activation + - agent + - detection + if: > + always() && (needs.detection.result == 'success' || needs.detection.result == 'skipped') && + needs.agent.result == 'success' + runs-on: ubuntu-slim permissions: {} env: GH_AW_WORKFLOW_ID_SANITIZED: testgapfinder steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -1067,8 +1223,8 @@ jobs: fi - name: Save cache-memory to cache (default) if: steps.check_cache_default.outputs.has_content == 'true' - uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: - key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} + key: memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} path: /tmp/gh-aw/cache-memory diff --git a/.github/workflows/update-awf-version.lock.yml b/.github/workflows/update-awf-version.lock.yml index eb8c98c..e46f3c3 100644 --- a/.github/workflows/update-awf-version.lock.yml +++ b/.github/workflows/update-awf-version.lock.yml @@ -1,3 +1,5 @@ +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1a67f7d6519b4cb5693bc9f57a290b6af80255a98e046ffde1a394b4adc50186","compiler_version":"v0.68.1","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9"},{"repo":"actions/upload-artifact","sha":"bbbca2ddaa5d8feaa63e36b76fdaad77386f024f","version":"v7"},{"repo":"github/gh-aw-actions/setup","sha":"2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc","version":"v0.68.1"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -12,7 +14,7 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.62.0). DO NOT EDIT. +# This file was automatically generated by gh-aw (v0.68.1). DO NOT EDIT. # # To update this file, edit the corresponding .md file and run: # gh aw compile @@ -20,28 +22,48 @@ # # For more information: https://github.github.com/gh-aw/introduction/overview/ # -# Checks for new releases of gh-aw-firewall and opens a PR to update the AWF_VERSION constant +# Checks for new releases of gh-aw-firewall and copilot-cli, and opens PRs to update pinned version constants # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"b991f6b4afcb78c714c46ba2984d6e655428d74b54b3a0c69db5b59f869562fc","compiler_version":"v0.62.0","strict":true} +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_CI_TRIGGER_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 +# - actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 +# - github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 -name: "AWF Version Updater" +name: "Dependency Version Updater" "on": schedule: - - cron: "43 10 * * *" + - cron: "19 11 * * *" # Friendly format: daily (scattered) workflow_dispatch: + inputs: + aw_context: + default: "" + description: Agent caller context (used internally by Agentic Workflows). + required: false + type: string permissions: {} concurrency: group: "gh-aw-${{ github.workflow }}" -run-name: "AWF Version Updater" +run-name: "Dependency Version Updater" jobs: activation: runs-on: ubuntu-slim permissions: + actions: read contents: read outputs: comment_id: "" @@ -49,40 +71,44 @@ jobs: lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} - name: Generate agentic run info id: generate_aw_info env: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_INFO_VERSION: "" - GH_AW_INFO_AGENT_VERSION: "latest" - GH_AW_INFO_CLI_VERSION: "v0.62.0" - GH_AW_INFO_WORKFLOW_NAME: "AWF Version Updater" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" + GH_AW_INFO_CLI_VERSION: "v0.68.1" + GH_AW_INFO_WORKFLOW_NAME: "Dependency Version Updater" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.24.3" + GH_AW_INFO_AWF_VERSION: "v0.25.18" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Validate COPILOT_GITHUB_TOKEN secret id: validate-secret - run: ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders @@ -94,20 +120,32 @@ jobs: .agents sparse-checkout-cone-mode: true fetch-depth: 1 - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_WORKFLOW_FILE: "update-awf-version.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_COMPILED_VERSION: "v0.68.1" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} @@ -116,22 +154,23 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec run: | - bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_e64d693612e00cf8_EOF' - GH_AW_PROMPT_EOF + GH_AW_PROMPT_e64d693612e00cf8_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_e64d693612e00cf8_EOF' - Tools: create_pull_request, missing_tool, missing_data, noop - GH_AW_PROMPT_EOF + Tools: create_pull_request(max:2), missing_tool, missing_data, noop + GH_AW_PROMPT_e64d693612e00cf8_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_e64d693612e00cf8_EOF' The following GitHub context information is available for this workflow: @@ -161,27 +200,25 @@ jobs: {{/if}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_e64d693612e00cf8_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' + cat << 'GH_AW_PROMPT_e64d693612e00cf8_EOF' - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/update-awf-version.md}} - GH_AW_PROMPT_EOF + GH_AW_PROMPT_e64d693612e00cf8_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); await main(); - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_GITHUB_ACTOR: ${{ github.actor }} @@ -195,7 +232,7 @@ jobs: with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); @@ -216,19 +253,23 @@ jobs: - name: Validate prompt placeholders env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" - name: Upload activation artifact if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: activation path: | /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/github_rate_limits.jsonl + if-no-files-found: ignore retention-days: 1 agent: @@ -249,68 +290,73 @@ jobs: GH_AW_WORKFLOW_ID_SANITIZED: updateawfversion outputs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} - detection_success: ${{ steps.detection_conclusion.outputs.success }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} has_patch: ${{ steps.collect_output.outputs.has_patch }} inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} model: ${{ needs.activation.outputs.model }} output: ${{ steps.collect_output.outputs.output }} output_types: ${{ steps.collect_output.outputs.output_types }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Set runtime paths + id: set-runtime-paths run: | - echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_ENV" - echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_ENV" + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Create gh-aw temp directory - run: bash ${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise - run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" env: GH_TOKEN: ${{ github.token }} - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Checkout PR branch id: checkout-pr if: | - (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 env: GH_HOST: github.com - name: Install AWF binary - run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.3 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 env: GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} @@ -319,124 +365,144 @@ jobs: const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.3 ghcr.io/github/gh-aw-firewall/squid:0.24.3 ghcr.io/github/gh-aw-mcpg:v0.1.19 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 ghcr.io/github/gh-aw-mcpg:v0.2.17 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config run: | - mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"create_pull_request":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_7e514d4d02d45859_EOF' + {"create_pull_request":{"max":2,"max_patch_size":1024,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS"],"protected_path_prefixes":[".github/",".agents/"]},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_7e514d4d02d45859_EOF - name: Write Safe Outputs Tools - run: | - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF' - { - "description_suffixes": { - "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created." - }, - "repo_params": {}, - "dynamic_tools": [] - } - GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "create_pull_request": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "branch": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "draft": { - "type": "boolean" - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_pull_request": " CONSTRAINTS: Maximum 2 pull request(s) can be created." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "draft": { + "type": "boolean" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } } - } - }, - "missing_data": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "context": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "data_type": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "reason": { - "type": "string", - "sanitize": true, - "maxLength": 256 + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } } } } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); - name: Generate Safe Outputs MCP Server Config id: safe-outputs-config run: | @@ -459,6 +525,7 @@ jobs: id: safe-outputs-start env: DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json @@ -467,18 +534,19 @@ jobs: run: | # Environment variables are set above to prevent template injection export DEBUG + export GH_AW_SAFE_OUTPUTS export GH_AW_SAFE_OUTPUTS_PORT export GH_AW_SAFE_OUTPUTS_API_KEY export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" - name: Start MCP Gateway id: start-mcp-gateway env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} @@ -500,10 +568,10 @@ jobs: export DEBUG="*" export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.19' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.17' mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_3df14ab9d8aea843_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh" { "mcpServers": { "github": { @@ -544,7 +612,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_EOF + GH_AW_MCP_CONFIG_3df14ab9d8aea843_EOF - name: Download activation artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -552,7 +620,7 @@ jobs: path: /tmp/gh-aw - name: Clean git credentials continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): @@ -560,9 +628,10 @@ jobs: run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} @@ -570,8 +639,8 @@ jobs: GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_VERSION: v0.62.0 + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.68.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_HEAD_REF: ${{ github.head_ref }} @@ -589,36 +658,24 @@ jobs: id: detect-inference-error if: always() continue-on-error: true - run: bash ${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh" - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Copy Copilot session state files to logs if: always() continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" - name: Stop MCP Gateway if: always() continue-on-error: true @@ -627,14 +684,14 @@ jobs: MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} run: | - bash ${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" - name: Redact secrets in logs if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); await main(); env: @@ -645,45 +702,48 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Append agent step summary if: always() - run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" - name: Copy Safe Outputs if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} run: | mkdir -p /tmp/gh-aw cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true - name: Ingest agent output id: collect_output if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); await main(); - name: Parse agent logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); await main(); - name: Parse MCP Gateway logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); await main(); - name: Print firewall logs @@ -701,10 +761,26 @@ jobs: else echo 'AWF binary not installed, skipping firewall log summary' fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: agent path: | @@ -712,144 +788,35 @@ jobs: /tmp/gh-aw/sandbox/agent/logs/ /tmp/gh-aw/redacted-urls.log /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent-stdio.log /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/safeoutputs.jsonl /tmp/gh-aw/agent_output.json /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle if-no-files-found: ignore - # --- Threat Detection (inline) --- - - name: Check if detection needed - id: detection_guard + - name: Upload firewall audit logs if: always() - env: - OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - run: | - if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then - echo "run_detection=true" >> "$GITHUB_OUTPUT" - echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" - else - echo "run_detection=false" >> "$GITHUB_OUTPUT" - echo "Detection skipped: no agent outputs or patches to analyze" - fi - - name: Clear MCP configuration for detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - rm -f /tmp/gh-aw/mcp-config/mcp-servers.json - rm -f /home/runner/.copilot/mcp-config.json - rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" - - name: Prepare threat detection files - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - - name: Setup threat detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_NAME: "AWF Version Updater" - WORKFLOW_DESCRIPTION: "Checks for new releases of gh-aw-firewall and opens a PR to update the AWF_VERSION constant" - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); - await main(); - - name: Ensure threat-detection directory and log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection - touch /tmp/gh-aw/threat-detection/detection.log - - name: Execute GitHub Copilot CLI - if: always() && steps.detection_guard.outputs.run_detection == 'true' - id: detection_agentic_execution - # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) - timeout-minutes: 20 - run: | - set -o pipefail - touch /tmp/gh-aw/agent-step-summary.md - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} - GH_AW_PHASE: detection - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_VERSION: v0.62.0 - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_AW: true - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md - GITHUB_WORKSPACE: ${{ github.workspace }} - GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_AUTHOR_NAME: github-actions[bot] - GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: github-actions[bot] - XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_detection_results - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - - name: Upload threat detection log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: detection - path: /tmp/gh-aw/threat-detection/detection.log + name: firewall-audit-logs + path: | + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ if-no-files-found: ignore - - name: Set detection conclusion - id: detection_conclusion - if: always() - env: - RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} - DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} - run: | - if [[ "$RUN_DETECTION" != "true" ]]; then - echo "conclusion=skipped" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection was not needed, marking as skipped" - elif [[ "$DETECTION_SUCCESS" == "true" ]]; then - echo "conclusion=success" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection passed successfully" - else - echo "conclusion=failure" >> "$GITHUB_OUTPUT" - echo "success=false" >> "$GITHUB_OUTPUT" - echo "Detection found issues" - fi conclusion: needs: - activation - agent + - detection - safe_outputs - if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') runs-on: ubuntu-slim permissions: contents: write @@ -859,14 +826,18 @@ jobs: group: "gh-aw-conclusion-update-awf-version" cancel-in-progress: false outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} noop_message: ${{ steps.noop.outputs.noop_message }} tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -875,54 +846,75 @@ jobs: name: agent path: /tmp/gh-aw/ - name: Setup agent output environment variable + id: setup-agent-output-env if: steps.download-agent-output.outcome == 'success' run: | mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Process No-Op Messages id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_NOOP_MAX: "1" - GH_AW_WORKFLOW_NAME: "AWF Version Updater" + GH_AW_WORKFLOW_NAME: "Dependency Version Updater" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); await main(); - - name: Record Missing Tool + - name: Record missing tool id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "AWF Version Updater" + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Dependency Version Updater" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); await main(); - - name: Handle Agent Failure + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Dependency Version Updater" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure id: handle_agent_failure if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "AWF Version Updater" + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Dependency Version Updater" GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_WORKFLOW_ID: "update-awf-version" + GH_AW_ENGINE_ID: "copilot" GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} GH_AW_GROUP_REPORTS: "false" GH_AW_FAILURE_REPORT_AS_ISSUE: "true" GH_AW_TIMEOUT_MINUTES: "20" @@ -930,46 +922,167 @@ jobs: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + + detection: + needs: + - activation + - agent + if: > + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 + - name: Check if detection needed + id: detection_guard + if: always() env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "AWF Version Updater" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + WORKFLOW_NAME: "Dependency Version Updater" + WORKFLOW_DESCRIPTION: "Checks for new releases of gh-aw-firewall and copilot-cli, and opens PRs to update pinned version constants" + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); await main(); - - name: Handle Create Pull Request Error - id: handle_create_pr_error - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "AWF Version Updater" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) + # shellcheck disable=SC1003 + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,telemetry.enterprise.githubcopilot.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: v0.68.1 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: detection + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Parse and conclude threat detection + id: detection_conclusion + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_create_pr_error.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); await main(); safe_outputs: needs: - activation - agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' runs-on: ubuntu-slim permissions: contents: write @@ -978,9 +1091,11 @@ jobs: timeout-minutes: 15 env: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/update-awf-version" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} GH_AW_WORKFLOW_ID: "update-awf-version" - GH_AW_WORKFLOW_NAME: "AWF Version Updater" + GH_AW_WORKFLOW_NAME: "Dependency Version Updater" outputs: code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} @@ -992,9 +1107,12 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw-actions/setup@b2c35f34e1013dd9ed2a84c559e2b2fec9ad38e6 # v0.62.0 + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 with: destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1003,11 +1121,12 @@ jobs: name: agent path: /tmp/gh-aw/ - name: Setup agent output environment variable + id: setup-agent-output-env if: steps.download-agent-output.outcome == 'success' run: | mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Download patch artifact continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -1015,7 +1134,7 @@ jobs: name: agent path: /tmp/gh-aw/ - name: Checkout repository - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} @@ -1023,7 +1142,7 @@ jobs: persist-credentials: false fetch-depth: 1 - name: Configure Git credentials - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} @@ -1037,6 +1156,7 @@ jobs: git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config shell: bash run: | # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct @@ -1046,26 +1166,26 @@ jobs: echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - name: Process Safe Outputs id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"max\":1,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"AGENTS.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\"]},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"max\":2,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"AGENTS.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\"]},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); + setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - - name: Upload Safe Output Items Manifest + - name: Upload Safe Outputs Items if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn + name: safe-outputs-items + path: /tmp/gh-aw/safe-output-items.jsonl + if-no-files-found: ignore diff --git a/.github/workflows/update-awf-version.md b/.github/workflows/update-awf-version.md index b03f7f1..a7d28ec 100644 --- a/.github/workflows/update-awf-version.md +++ b/.github/workflows/update-awf-version.md @@ -1,7 +1,7 @@ --- on: schedule: daily -description: Checks for new releases of gh-aw-firewall and opens a PR to update the AWF_VERSION constant +description: Checks for new releases of gh-aw-firewall and copilot-cli, and opens PRs to update pinned version constants permissions: contents: read issues: read @@ -13,56 +13,87 @@ network: allowed: [defaults] safe-outputs: create-pull-request: - max: 1 + max: 2 --- -# AWF Version Updater +# Dependency Version Updater You are a dependency maintenance bot for the **ado-aw** project — a Rust CLI compiler that transforms markdown agent definitions into Azure DevOps pipeline YAML. ## Your Task -Check whether the `AWF_VERSION` constant in `src/compile/common.rs` is up to date with the latest release of [gh-aw-firewall](https://github.com/github/gh-aw-firewall). If a newer version is available, open a PR to update it. +Check whether pinned version constants in `src/compile/common.rs` are up to date with the latest releases of their upstream dependencies. For each outdated constant, open a PR to update it. -## Step 1: Get the Latest gh-aw-firewall Release +There are two dependencies to check: -Fetch the latest release of the `github/gh-aw-firewall` repository. Record the tag name, stripping any leading `v` prefix to get the bare version number (e.g. `v0.24.0` → `0.24.0`). +| Constant | Upstream Repository | Example value | +|----------|-------------------|---------------| +| `AWF_VERSION` | [github/gh-aw-firewall](https://github.com/github/gh-aw-firewall) | `0.25.14` | +| `COPILOT_CLI_VERSION` | [github/copilot-cli](https://github.com/github/copilot-cli) | `1.0.6` | -## Step 2: Read the Current AWF_VERSION +Run the following steps **independently for each dependency**. One may be up to date while the other is not. -Read the file `src/compile/common.rs` in this repository and find the line: +--- + +## For each dependency: + +### Step 1: Get the Latest Release + +Fetch the latest release of the upstream repository. Record the tag name, stripping any leading `v` prefix to get the bare version number (e.g. `v0.24.0` → `0.24.0`). + +### Step 2: Read the Current Version + +Read the file `src/compile/common.rs` in this repository and find the corresponding constant: + +- `pub const AWF_VERSION: &str = "...";` +- `pub const COPILOT_CLI_VERSION: &str = "...";` + +Extract the version string. + +### Step 3: Compare Versions + +If the current constant already matches the latest release, **skip this dependency** — it is up to date. + +Before proceeding, also check whether a PR already exists with a title matching the expected PR title (see Step 4). If one is already open, **skip this dependency** to avoid duplicates. + +### Step 4: Create an Update PR + +If the latest version is newer than the current constant: -```rust -pub const AWF_VERSION: &str = "..."; -``` +1. Edit `src/compile/common.rs` — update **only** the relevant version string literal. Do not modify anything else in the file. -Extract the version string from that line. +2. Create a pull request: -## Step 3: Compare Versions +**For AWF_VERSION:** +- **Title**: `chore: update AWF_VERSION to ` +- **Body**: + ```markdown + ## Dependency Update -If the current `AWF_VERSION` already matches the latest release, **do nothing and stop**. The dependency is up to date. + Updates the pinned `AWF_VERSION` constant in `src/compile/common.rs` from `` to ``. -Before proceeding, also check whether a PR already exists with a title matching `chore: update AWF_VERSION to `. If one is already open, **do nothing and stop** to avoid duplicates. + ### Release -## Step 4: Create an Update PR + See the [gh-aw-firewall release notes](https://github.com/github/gh-aw-firewall/releases/tag/v) for details. -If the latest version is newer than `AWF_VERSION`: + --- + *This PR was opened automatically by the dependency version updater workflow.* + ``` -1. Edit `src/compile/common.rs` — update the `AWF_VERSION` string literal to the new version. Change only that single string value; do not modify anything else in the file. +**For COPILOT_CLI_VERSION:** +- **Title**: `chore: update COPILOT_CLI_VERSION to ` +- **Body**: + ```markdown + ## Dependency Update -2. Create a pull request with: - - **Title**: `chore: update AWF_VERSION to ` - - **Body**: - ```markdown - ## Dependency Update + Updates the pinned `COPILOT_CLI_VERSION` constant in `src/compile/common.rs` from `` to ``. - Updates the pinned `AWF_VERSION` constant in `src/compile/common.rs` from `` to ``. + ### Release - ### Release + See the [copilot-cli release notes](https://github.com/github/copilot-cli/releases/tag/v) for details. - See the [gh-aw-firewall release notes](https://github.com/github/gh-aw-firewall/releases/tag/v) for details. + --- + *This PR was opened automatically by the dependency version updater workflow.* + ``` - --- - *This PR was opened automatically by the AWF version updater workflow.* - ``` - - **Base branch**: `main` +- **Base branch**: `main` diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..820b532 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.8.3" +} diff --git a/AGENTS.md b/AGENTS.md index e664207..106933c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ Alongside the correctly generated pipeline yaml, an agent file is generated from │ ├── fuzzy_schedule.rs # Fuzzy schedule parsing │ ├── logging.rs # File-based logging infrastructure │ ├── mcp.rs # SafeOutputs MCP server (stdio + HTTP) +│ ├── configure.rs # `configure` CLI command — detects and updates pipeline variables +│ ├── detect.rs # Agentic pipeline detection (helper for `configure`) │ ├── ndjson.rs # NDJSON parsing utilities │ ├── proxy.rs # Network proxy implementation │ ├── sanitize.rs # Input sanitization for safe outputs @@ -99,7 +101,6 @@ target: standalone # Optional: "standalone" (default) or "1es". See Target Platf engine: claude-opus-4.5 # AI engine to use. Defaults to claude-opus-4.5. Other options include claude-sonnet-4.5, gpt-5.2-codex, gemini-3-pro-preview, etc. # engine: # Alternative object format (with additional options) # model: claude-opus-4.5 -# max-turns: 50 # timeout-minutes: 30 schedule: daily around 14:00 # Fuzzy schedule syntax - see Schedule Syntax section below # schedule: # Alternative object format (with branch filtering) @@ -269,6 +270,39 @@ schedule: - release/* ``` +### Engine Configuration + +The `engine` field specifies which AI model to use and optional execution parameters. It accepts both a simple string format (model name only) and an object format with additional options. + +```yaml +# Simple string format (just a model name) +engine: claude-opus-4.5 + +# Object format with additional options +engine: + model: claude-opus-4.5 + timeout-minutes: 30 +``` + +#### Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `model` | string | `claude-opus-4.5` | AI model to use. Options include `claude-sonnet-4.5`, `gpt-5.2-codex`, `gemini-3-pro-preview`, etc. | +| `timeout-minutes` | integer | *(none)* | Maximum time in minutes the agent job is allowed to run. Sets `timeoutInMinutes` on the `PerformAgenticTask` job in the generated pipeline. | + +> **Deprecated:** `max-turns` is still accepted in front matter for backwards compatibility but is ignored at compile time (a warning is emitted). It was specific to Claude Code and is not supported by Copilot CLI. + +#### `timeout-minutes` + +The `timeout-minutes` field sets a wall-clock limit (in minutes) for the entire agent job. It maps to the Azure DevOps `timeoutInMinutes` job property on `PerformAgenticTask`. This is useful for: + +- **Budget enforcement** — hard-capping the total runtime of an agent to control compute costs. +- **Pipeline hygiene** — preventing agents from occupying a runner indefinitely if they stall or enter long retry loops. +- **SLA compliance** — ensuring scheduled agents complete within a known window. + +When omitted, Azure DevOps uses its default job timeout (60 minutes). When set, the compiler emits `timeoutInMinutes: ` on the agentic job. + ### Tools Configuration The `tools` field controls which tools are available to the agent. Both sub-fields are optional and have sensible defaults. @@ -350,7 +384,7 @@ The compiler transforms the input into valid Azure DevOps pipeline YAML based on - **Standalone**: Uses `templates/base.yml` - **1ES**: Uses `templates/1es-base.yml` -Explicit markings are embedded in these templates that the compiler is allowed to replace e.g. `{{ agency_params }}` denotes parameters which are passed to the agency command line tool. The compiler should not replace sections denoted by `${{ some content }}`. What follows is a mapping of markings to responsibilities (primarily for the standalone template). +Explicit markings are embedded in these templates that the compiler is allowed to replace e.g. `{{ copilot_params }}` denotes parameters which are passed to the copilot command line tool. The compiler should not replace sections denoted by `${{ some content }}`. What follows is a mapping of markings to responsibilities (primarily for the standalone template). ## {{ repositories }} For each additional repository specified in the front matter append: @@ -419,12 +453,13 @@ This distinction allows resources (like templates) to be available as pipeline r Should be replaced with the human-readable name from the front matter (e.g., "Daily Code Review"). This is used for display purposes like stage names. -## {{ agency_params }} +## {{ copilot_params }} -Additional params provided to agency CLI. The compiler generates: +Additional params provided to copilot CLI. The compiler generates: - `--model ` - AI model from `engine` front matter field (default: claude-opus-4.5) - `--no-ask-user` - Prevents interactive prompts - `--allow-tool ` - Explicitly allows specific tools (github, safeoutputs, write, shell commands like cat, date, echo, grep, head, ls, pwd, sort, tail, uniq, wc, yq) +- `--disable-mcp-server ` - Disables specific Copilot CLI MCPs MCP servers are handled entirely by the MCP Gateway (MCPG) and are not passed as copilot CLI params. @@ -480,6 +515,12 @@ Generates a `dependsOn: SetupJob` clause for `PerformAgenticTask` if a setup job If no setup job is configured, this is replaced with an empty string. +## {{ job_timeout }} + +Generates a `timeoutInMinutes: ` job property for `PerformAgenticTask` when `engine.timeout-minutes` is configured. This sets the Azure DevOps job-level timeout for the agentic task. + +If `timeout-minutes` is not configured, this is replaced with an empty string. + ## {{ working_directory }} Should be replaced with the appropriate working directory based on the effective workspace setting. @@ -495,7 +536,7 @@ Should be replaced with the appropriate working directory based on the effective - `root`: `$(Build.SourcesDirectory)` - the checkout root directory - `repo`: `$(Build.SourcesDirectory)/$(Build.Repository.Name)` - the repository's subfolder -This is used for the `workingDirectory` property of the agency copilot task. +This is used for the `workingDirectory` property of the copilot task. ## {{ source_path }} @@ -685,16 +726,16 @@ Should be replaced with the agent context root for 1ES Agency jobs. This determi ## {{ mcp_configuration }} -Should be replaced with the MCP server configuration for 1ES templates. For each enabled MCP with a service connection, generates service connection references: +Should be replaced with the MCP server configuration for 1ES templates. For each `mcp-servers:` entry without a `command:` field, generates a service connection reference using the entry name: ```yaml -ado: - serviceConnection: mcp-ado-service-connection -kusto: - serviceConnection: mcp-kusto-service-connection +my-mcp: + serviceConnection: mcp-my-mcp-service-connection +other-mcp: + serviceConnection: mcp-other-mcp-service-connection ``` -Custom MCP servers (with `command:` field) are not supported in 1ES target. MCPs must have service connection configuration. +Custom MCP servers (with `command:` field) are not supported in 1ES target. Only entries without a `command:` (which have a corresponding service connection) are supported. ## {{ global_options }} @@ -712,11 +753,11 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `--output, -o ` - Output directory for the generated file (defaults to current directory) - Guides you through: name, description, engine selection, schedule, workspace, repositories, checkout, and MCPs - The generated file includes a placeholder for agent instructions that you edit directly -- `compile ` - Compile a markdown file to Azure DevOps pipeline YAML - - `--output, -o ` - Optional output path for generated YAML -- `check ` - Verify that a compiled pipeline matches its source markdown - - `` - Path to the source markdown file +- `compile []` - Compile a markdown file to Azure DevOps pipeline YAML. If no path is given, auto-discovers and recompiles all detected agentic pipelines in the current directory. + - `--output, -o ` - Optional output path for generated YAML (only valid when a path is provided) +- `check ` - Verify that a compiled pipeline matches its source markdown - `` - Path to the pipeline YAML file to verify + - The source markdown path is auto-detected from the `@ado-aw` header in the pipeline file - Useful for CI checks to ensure pipelines are regenerated after source changes - `mcp ` - Run SafeOutputs as a stdio MCP server - `mcp-http ` - Run SafeOutputs as an HTTP MCP server (for MCPG integration) @@ -728,8 +769,14 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `--output-dir ` - Output directory for processed artifacts (e.g., agent memory) - `--ado-org-url ` - Azure DevOps organization URL override - `--ado-project ` - Azure DevOps project name override -- `proxy` - Start an HTTP proxy for network filtering - - `--allow ` - Allowed hosts (supports wildcards, can be repeated) +- `configure` - Detect agentic pipelines in a local repository and update the `GITHUB_TOKEN` pipeline variable on their Azure DevOps build definitions + - `--token ` / `GITHUB_TOKEN` env var - The new GITHUB_TOKEN value (prompted if omitted) + - `--org ` - Override: Azure DevOps organization URL (inferred from git remote by default) + - `--project ` - Override: Azure DevOps project name (inferred from git remote by default) + - `--pat ` / `AZURE_DEVOPS_EXT_PAT` env var - PAT for ADO API authentication (prompted if omitted) + - `--path ` - Path to the repository root (defaults to current directory) + - `--dry-run` - Preview changes without applying them + - `--definition-ids ` - Explicit pipeline definition IDs to update (comma-separated, skips auto-detection) ## Safe Outputs Configuration @@ -973,6 +1020,7 @@ safe-outputs: create-wiki-page: wiki-name: "MyProject.wiki" # Required — wiki identifier (name or GUID) wiki-project: "OtherProject" # Optional — ADO project that owns the wiki; defaults to current pipeline project + branch: "main" # Optional — git branch override; auto-detected for code wikis (see note below) path-prefix: "/agent-output" # Optional — prepended to the agent-supplied path (restricts write scope) title-prefix: "[Agent] " # Optional — prepended to the last path segment (the page title) comment: "Created by agent" # Optional — default commit comment when agent omits one @@ -981,6 +1029,8 @@ safe-outputs: Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message. +**Code wikis vs project wikis:** The executor automatically detects code wikis (type 1) and resolves the published branch from the wiki metadata. You only need to set `branch` explicitly to override the auto-detected value (e.g. targeting a non-default branch). Project wikis (type 0) need no branch configuration. + #### update-wiki-page Updates the content of an existing Azure DevOps wiki page. The wiki page must already exist; this tool edits its content but does not create new pages. @@ -995,6 +1045,7 @@ safe-outputs: update-wiki-page: wiki-name: "MyProject.wiki" # Required — wiki identifier (name or GUID) wiki-project: "OtherProject" # Optional — ADO project that owns the wiki; defaults to current pipeline project + branch: "main" # Optional — git branch override; auto-detected for code wikis (see note below) path-prefix: "/agent-output" # Optional — prepended to the agent-supplied path (restricts write scope) title-prefix: "[Agent] " # Optional — prepended to the last path segment (the page title) comment: "Updated by agent" # Optional — default commit comment when agent omits one @@ -1003,6 +1054,8 @@ safe-outputs: Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message. +**Code wikis vs project wikis:** The executor automatically detects code wikis (type 1) and resolves the published branch from the wiki metadata. You only need to set `branch` explicitly to override the auto-detected value (e.g. targeting a non-default branch). Project wikis (type 0) need no branch configuration. + ### Adding New Features When extending the compiler: @@ -1044,6 +1097,13 @@ cargo clippy cargo run -- compile ./path/to/agent.md ``` +### Recompile all agentic pipelines in the current directory + +```bash +# Auto-discovers and recompiles all detected agentic pipelines +cargo run -- compile +``` + ### Add a new dependency ```bash @@ -1059,6 +1119,7 @@ cargo add ## MCP Configuration The `mcp-servers:` field configures MCP (Model Context Protocol) servers that are made available to the agent via the MCP Gateway (MCPG). All MCPs require explicit `command:` configuration — there are no built-in MCPs in the copilot CLI. +The `mcp-servers:` field configures custom MCP (Model Context Protocol) servers that the agent can use. Each entry must include a `command:` field specifying the executable to spawn. ### Custom MCP Servers @@ -1076,13 +1137,13 @@ mcp-servers: ### Configuration Properties - - `command:` - The executable to run (e.g., `"node"`, `"python"`, `"dotnet"`) - `args:` - Array of command-line arguments passed to the command - `allowed:` - Array of function names agents are permitted to call (required for security) - `env:` - Optional environment variables for the MCP server process +- `service-connection:` - (1ES target only) Override the service connection name used for this MCP. If not specified, defaults to `mcp--service-connection` -### Example Configuration +### Example: Multiple Custom MCP Servers ```yaml mcp-servers: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7d65a..ea351d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,76 @@ # Changelog +## [0.8.3](https://github.com/githubnext/ado-aw/compare/v0.8.2...v0.8.3) (2026-04-02) + + +### Bug Fixes + +* handle string WikiType enum from ADO API in branch resolution ([#122](https://github.com/githubnext/ado-aw/issues/122)) ([7914169](https://github.com/githubnext/ado-aw/commit/791416976e805e440fe60a5829f79ebca2143cdb)) +* resolve check command source path from repo root ([#120](https://github.com/githubnext/ado-aw/issues/120)) ([a057598](https://github.com/githubnext/ado-aw/commit/a05759895352aa6504fa62ab74bde3ee62a9e25e)) + +## [0.8.2](https://github.com/githubnext/ado-aw/compare/v0.8.1...v0.8.2) (2026-04-02) + + +### Bug Fixes + +* use platform-appropriate absolute paths in path fallback tests ([#118](https://github.com/githubnext/ado-aw/issues/118)) ([434cf19](https://github.com/githubnext/ado-aw/commit/434cf19562563199e3114fbaaa04b3d3c33aad98)) + +## [0.8.1](https://github.com/githubnext/ado-aw/compare/v0.8.0...v0.8.1) (2026-04-02) + + +### Bug Fixes + +* auto-detect code wiki branch for wiki page safe outputs ([#115](https://github.com/githubnext/ado-aw/issues/115)) ([f8ea1e9](https://github.com/githubnext/ado-aw/commit/f8ea1e9b2c44ca7cce02398fa6a7fd2d20edf252)) +* preserve subdirectory in generated pipeline_path and source_path ([#114](https://github.com/githubnext/ado-aw/issues/114)) ([32137fe](https://github.com/githubnext/ado-aw/commit/32137fe1c3169ee7209fa71368abcbf9165ddc36)) + +## [0.8.0](https://github.com/githubnext/ado-aw/compare/ado-aw-v0.7.1...ado-aw-v0.8.0) (2026-04-01) + + +### ⚠ BREAKING CHANGES + +* \check \ is now \check \. Update any scripts or pipeline templates that call the old two-arg form. + +### Features + +* add /rust-review slash command for on-demand PR reviews ([#60](https://github.com/githubnext/ado-aw/issues/60)) ([8eaf972](https://github.com/githubnext/ado-aw/commit/8eaf972baed99bcd03f9d3bcae013bdb922e390a)) +* add \configure\ command to detect pipelines and update GITHUB_TOKEN ([#92](https://github.com/githubnext/ado-aw/issues/92)) ([a032b4e](https://github.com/githubnext/ado-aw/commit/a032b4e837df89fe5127100664f44415274f1030)) +* add comment-on-work-item safe output tool ([#80](https://github.com/githubnext/ado-aw/issues/80)) ([513f7fe](https://github.com/githubnext/ado-aw/commit/513f7feca82e4d6c2ae02906ea6231fa2ebf4530)) +* add create-wiki-page safe output ([#61](https://github.com/githubnext/ado-aw/issues/61)) ([87d6527](https://github.com/githubnext/ado-aw/commit/87d65276a084b5ab944f33083089cb2a7fe93434)) +* add edit-wiki-page safe output ([#58](https://github.com/githubnext/ado-aw/issues/58)) ([7b4536f](https://github.com/githubnext/ado-aw/commit/7b4536f1953c9d09bb3deee5a06779c06e4ac53e)) +* add update-work-item safe output ([#65](https://github.com/githubnext/ado-aw/issues/65)) ([cf5e6b5](https://github.com/githubnext/ado-aw/commit/cf5e6b5a0778dda1cfcbfeb0d5a19d89649ce43a)) +* Add Windows x64 binary to release artifacts ([#37](https://github.com/githubnext/ado-aw/issues/37)) ([d463006](https://github.com/githubnext/ado-aw/commit/d4630063c6f6fb8418fe0e37c3ef56abed1fa299)) +* allow copilot bot to trigger rust PR reviewer ([#59](https://github.com/githubnext/ado-aw/issues/59)) ([0bcef57](https://github.com/githubnext/ado-aw/commit/0bcef5799295157f6d1f3cda06de4c7fcd020730)) +* apply max budget enforcement to all safe-output tools ([#91](https://github.com/githubnext/ado-aw/issues/91)) ([e88d8da](https://github.com/githubnext/ado-aw/commit/e88d8da4e8370547b5a22b623c850287402abab6)) +* auto-detect source from header in check command ([#108](https://github.com/githubnext/ado-aw/issues/108)) ([b25f143](https://github.com/githubnext/ado-aw/commit/b25f1431928543af23a471210f2c4e8422e9d86e)) +* auto-discover and recompile all agentic pipelines ([#96](https://github.com/githubnext/ado-aw/issues/96)) ([fb1de50](https://github.com/githubnext/ado-aw/commit/fb1de50f6ac89d13a1876f8c5374c933139345ee)) +* **configure:** accept explicit definition IDs via --definition-ids ([#100](https://github.com/githubnext/ado-aw/issues/100)) ([b12c5ff](https://github.com/githubnext/ado-aw/commit/b12c5ffb493479976b555b115f805eca63e0c967)) +* Download releases from GitHub. ([#17](https://github.com/githubnext/ado-aw/issues/17)) ([8478453](https://github.com/githubnext/ado-aw/commit/847845351026c7683f5f852ac06c084c2c2fe00f)) +* rename edit-wiki-page to update-wiki-page ([#66](https://github.com/githubnext/ado-aw/issues/66)) ([2b6c5ed](https://github.com/githubnext/ado-aw/commit/2b6c5ed5bbfd5874231adaac3d27f45ac0c1d3f1)) +* replace read-only-service-connection with permissions field ([#26](https://github.com/githubnext/ado-aw/issues/26)) ([410e2df](https://github.com/githubnext/ado-aw/commit/410e2dff48c56dd3e66773e7c2f6cb6295eb9055)) + + +### Bug Fixes + +* add --repo flag to gh release upload in checksums job ([#40](https://github.com/githubnext/ado-aw/issues/40)) ([fd437da](https://github.com/githubnext/ado-aw/commit/fd437daf148e63f2c768fd9c6365c5c8ac4ef871)) +* **configure:** support Azure CLI auth and fix YAML path matching ([#98](https://github.com/githubnext/ado-aw/issues/98)) ([a771036](https://github.com/githubnext/ado-aw/commit/a771036b21849f5769bc3d44cb72346a93d73bac)) +* pin AWF container images to specific firewall version ([#30](https://github.com/githubnext/ado-aw/issues/30)) ([bb92c9c](https://github.com/githubnext/ado-aw/commit/bb92c9ccc6b5edbfa6b0ddeabca1cbe0cd39dd98)) +* pin AWF container images to specific firewall version ([#32](https://github.com/githubnext/ado-aw/issues/32)) ([9c3b85c](https://github.com/githubnext/ado-aw/commit/9c3b85c3029a513f75dc354be3b6052098cd43db)) +* pin DockerInstaller to v26.1.4 for API compatibility ([#105](https://github.com/githubnext/ado-aw/issues/105)) ([2c6baf2](https://github.com/githubnext/ado-aw/commit/2c6baf28766981ec92d2129def1060f821b0393d)) +* quote chmod paths and remove fragile sha256sum pipe in templates ([#43](https://github.com/githubnext/ado-aw/issues/43)) ([b246fb1](https://github.com/githubnext/ado-aw/commit/b246fb157177994224eb4c4a8930f11148a653c3)) +* sha256sum --ignore-missing silently passes when binary is absent from checksums.txt ([#47](https://github.com/githubnext/ado-aw/issues/47)) ([26c03c4](https://github.com/githubnext/ado-aw/commit/26c03c4e1c9c0ffde1e1570a70fbe90744c9383f)) +* strip redundant ./ prefixes from source path in header comment ([#106](https://github.com/githubnext/ado-aw/issues/106)) ([689825c](https://github.com/githubnext/ado-aw/commit/689825c6ab073864e8b380e3ee86ec3c2d120f45)) +* **tests:** strengthen checksum verification assertion against regression ([#48](https://github.com/githubnext/ado-aw/issues/48)) ([7fcabe2](https://github.com/githubnext/ado-aw/commit/7fcabe2b4494dd694127d4f11ed7aad98b6b21e9)) +* update Copilot CLI version to 1.0.6 via compiler constants ([#51](https://github.com/githubnext/ado-aw/issues/51)) ([b8d8ece](https://github.com/githubnext/ado-aw/commit/b8d8ece8777b1517a6d8ced9c0c89f85ac088932)) +* YAML path matching and legacy SSH URL support ([#95](https://github.com/githubnext/ado-aw/issues/95)) ([f85dd39](https://github.com/githubnext/ado-aw/commit/f85dd39b5038be49ce6ed0f67d34d170090999e4)) + +## [0.7.1](https://github.com/githubnext/ado-aw/compare/v0.7.0...v0.7.1) (2026-04-01) + + +### Bug Fixes + +* pin DockerInstaller to v26.1.4 for API compatibility ([#105](https://github.com/githubnext/ado-aw/issues/105)) ([2c6baf2](https://github.com/githubnext/ado-aw/commit/2c6baf28766981ec92d2129def1060f821b0393d)) +* strip redundant ./ prefixes from source path in header comment ([#106](https://github.com/githubnext/ado-aw/issues/106)) ([689825c](https://github.com/githubnext/ado-aw/commit/689825c6ab073864e8b380e3ee86ec3c2d120f45)) + ## [0.7.0](https://github.com/githubnext/ado-aw/compare/v0.6.1...v0.7.0) (2026-03-31) diff --git a/Cargo.lock b/Cargo.lock index e9ab8bf..833e7bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "ado-aw" -version = "0.7.0" +version = "0.8.3" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index d0d0940..6c650d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ado-aw" -version = "0.7.0" +version = "0.8.3" edition = "2024" [dependencies] diff --git a/prompts/create-ado-agentic-workflow.md b/prompts/create-ado-agentic-workflow.md index e1328c6..00a97c0 100644 --- a/prompts/create-ado-agentic-workflow.md +++ b/prompts/create-ado-agentic-workflow.md @@ -50,7 +50,6 @@ Object form with extra options: ```yaml engine: model: claude-sonnet-4.5 - max-turns: 50 timeout-minutes: 30 ``` diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..23a61c1 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-component-in-tag": false, + "packages": { + ".": { + "release-type": "rust", + "bump-minor-pre-major": true + } + } +} diff --git a/src/compile/common.rs b/src/compile/common.rs index 42f0e57..059bb4a 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2,7 +2,8 @@ use anyhow::{Context, Result}; -use super::types::{FrontMatter, McpConfig, Repository, TriggerConfig}; +use super::types::{FrontMatter, Repository, TriggerConfig}; +use crate::compile::types::McpConfig; use crate::fuzzy_schedule; /// Check if an MCP has a custom command (i.e., is not just a name-based reference). @@ -306,11 +307,29 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String { let mut params = Vec::new(); params.push(format!("--model {}", front_matter.engine.model())); + if front_matter.engine.max_turns().is_some() { + eprintln!( + "Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \ + and will be ignored. Consider removing it from the engine configuration.", + front_matter.name + ); + } + if let Some(timeout_minutes) = front_matter.engine.timeout_minutes() { + if timeout_minutes == 0 { + eprintln!( + "Warning: Agent '{}' has timeout-minutes: 0, which means no time is allowed. \ + The agent job will time out immediately. \ + Consider setting timeout-minutes to at least 1.", + front_matter.name + ); + } + } + params.push("--disable-builtin-mcps".to_string()); params.push("--no-ask-user".to_string()); for tool in allowed_tools { if tool.contains('(') || tool.contains(')') || tool.contains(' ') { - // Use double quotes - the agency_params are embedded inside a single-quoted + // Use double quotes - the copilot_params are embedded inside a single-quoted // bash string in the AWF command, so single quotes would break quoting. params.push(format!("--allow-tool \"{}\"", tool)); } else { @@ -353,6 +372,15 @@ pub fn generate_working_directory(effective_workspace: &str) -> String { } } +/// Generate `timeoutInMinutes` job property from `engine.timeout-minutes`. +/// Returns an empty string when timeout is not configured. +pub fn generate_job_timeout(front_matter: &FrontMatter) -> String { + match front_matter.engine.timeout_minutes() { + Some(minutes) => format!("timeoutInMinutes: {}", minutes), + None => String::new(), + } +} + /// Format a single step's YAML string with proper indentation pub fn format_step_yaml(step_yaml: &str) -> String { let trimmed = step_yaml.trim(); @@ -430,12 +458,12 @@ pub const DEFAULT_POOL: &str = "AZS-1ES-L-MMS-ubuntu-22.04"; /// Version of the AWF (Agentic Workflow Firewall) binary to download from GitHub Releases. /// Update this when upgrading to a new AWF release. /// See: https://github.com/github/gh-aw-firewall/releases -pub const AWF_VERSION: &str = "0.25.5"; +pub const AWF_VERSION: &str = "0.25.17"; /// Version of the GitHub Copilot CLI (Microsoft.Copilot.CLI.linux-x64) NuGet package to install. /// Update this when upgrading to a new Copilot CLI release. /// See: https://pkgs.dev.azure.com/msazuresphere/_packaging/Guardian1ESPTUpstreamOrgFeed/nuget/v3/index.json -pub const COPILOT_CLI_VERSION: &str = "1.0.6"; +pub const COPILOT_CLI_VERSION: &str = "1.0.21"; /// Prefix used to identify agentic pipeline YAML files generated by ado-aw. pub const HEADER_MARKER: &str = "# @ado-aw"; @@ -451,13 +479,19 @@ pub const HEADER_MARKER: &str = "# @ado-aw"; /// are normalized to forward slashes for cross-platform consistency. pub fn generate_header_comment(input_path: &std::path::Path) -> String { let version = env!("CARGO_PKG_VERSION"); - let source_path = input_path + let mut source_path = input_path .to_string_lossy() .replace('\\', "/") .replace('\n', "") .replace('\r', "") .replace('"', "\\\""); + // Strip redundant leading "./" prefixes to prevent accumulation when + // compile_all_pipelines re-joins paths through Path::new(".").join(source). + while source_path.starts_with("./") { + source_path = source_path[2..].to_string(); + } + format!( "# This file is auto-generated by ado-aw. Do not edit manually.\n\ # @ado-aw source=\"{}\" version={}\n", @@ -477,26 +511,108 @@ pub const MCPG_PORT: u16 = 80; /// /// Returns a path using `{{ workspace }}` as the base, which gets resolved /// to the correct ADO working directory before this placeholder is replaced. +/// +/// The full relative path of the input file is preserved so that agents compiled +/// from subdirectories (e.g. `ado-aw compile agents/ctf.md`) produce a correct +/// runtime path (`$(Build.SourcesDirectory)/agents/ctf.md`) rather than a path +/// that drops the directory component. +/// +/// Absolute paths fall back to using only the filename to avoid embedding +/// machine-specific paths in the generated pipeline. pub fn generate_source_path(input_path: &std::path::Path) -> String { - let filename = input_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("agent.md"); - - format!("{{{{ workspace }}}}/agents/{}", filename) + let relative = normalize_relative_path(input_path).unwrap_or_else(|| { + input_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("agent.md") + .to_string() + }); + + format!("{{{{ workspace }}}}/{}", relative) } /// Generate the pipeline YAML path for integrity checking at ADO runtime. /// /// Returns a path using `{{ workspace }}` as the base, derived from the -/// output path's filename so it matches whatever `-o` was specified during compilation. +/// output path so it matches whatever `-o` was specified during compilation. +/// +/// The full relative path is preserved so that pipelines compiled into +/// subdirectories (e.g. `agents/ctf.yml`) produce a correct runtime path +/// (`$(Build.SourcesDirectory)/agents/ctf.yml`) rather than a path that +/// drops the directory component. +/// +/// Absolute paths fall back to using only the filename to avoid embedding +/// machine-specific paths in the generated pipeline. pub fn generate_pipeline_path(output_path: &std::path::Path) -> String { - let filename = output_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("pipeline.yml"); + let relative = normalize_relative_path(output_path).unwrap_or_else(|| { + output_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("pipeline.yml") + .to_string() + }); + + format!("{{{{ workspace }}}}/{}", relative) +} + +/// Normalize a path for embedding in a generated pipeline. +/// +/// Returns `Some(String)` when `path` is relative, with: +/// - Backslashes converted to forward slashes +/// - Redundant leading `./` prefixes stripped +/// +/// For absolute paths the function first tries to compute a relative path from +/// the nearest git repository root (found by walking up the directory tree +/// looking for a `.git` entry). This preserves the directory structure when +/// the user passes an absolute path — e.g. +/// `/home/user/repo/agents/ctf.md` → `agents/ctf.md`. +/// +/// Falls back to `None` (callers use filename-only) only when no git root is +/// found, to avoid embedding machine-specific absolute paths in the generated +/// pipeline YAML. +/// +/// Note: `..` components in relative paths are passed through unchanged. +/// Callers are responsible for ensuring the path does not traverse outside the +/// repository checkout. +fn normalize_relative_path(path: &std::path::Path) -> Option { + if path.is_absolute() { + // Try to make the path relative to the nearest git repo root so that + // directory structure (e.g. `agents/ctf.md`) is preserved even when + // the user invokes the compiler with an absolute path. + if let Some(git_root) = find_git_root(path) { + if let Ok(rel) = path.strip_prefix(&git_root) { + let s = rel.to_string_lossy().replace('\\', "/"); + return Some(s); + } + } + return None; + } + + let mut s = path.to_string_lossy().replace('\\', "/"); + while let Some(stripped) = s.strip_prefix("./") { + s = stripped.to_string(); + } + Some(s) +} - format!("{{{{ workspace }}}}/{}", filename) +/// Walk up the directory tree from `path` looking for a `.git` entry. +/// +/// Returns the first ancestor directory that contains `.git`, or `None` if the +/// traversal reaches the filesystem root without finding one. +fn find_git_root(path: &std::path::Path) -> Option { + // Start from the file's parent directory (or the path itself if it is a dir). + let start: &std::path::Path = if path.is_dir() { path } else { path.parent()? }; + + let mut current = start.to_path_buf(); + loop { + if current.join(".git").exists() { + return Some(current); + } + match current.parent() { + Some(parent) => current = parent.to_path_buf(), + None => return None, + } + } } // ==================== Permission helpers ==================== @@ -563,6 +679,17 @@ const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &[ "update-work-item", "create-wiki-page", "update-wiki-page", + "add-pr-comment", + "link-work-items", + "queue-build", + "create-git-tag", + "add-build-tag", + "create-branch", + "update-pr", + "upload-attachment", + "submit-pr-review", + "reply-to-pr-review-comment", + "resolve-pr-review-thread", ]; /// Validate that write-requiring safe-outputs have a write service connection configured. @@ -644,6 +771,118 @@ pub fn validate_update_work_item_target(front_matter: &FrontMatter) -> Result<() Ok(()) } +/// Validate that submit-pr-review has a required `allowed-events` field when configured. +/// +/// An empty or missing `allowed-events` list would allow agents to cast any review vote, +/// including auto-approvals. Operators must explicitly opt in to each allowed event. +pub fn validate_submit_pr_review_events(front_matter: &FrontMatter) -> Result<()> { + if let Some(config_value) = front_matter.safe_outputs.get("submit-pr-review") { + if let Some(obj) = config_value.as_object() { + let allowed_events = obj.get("allowed-events"); + let is_empty = match allowed_events { + None => true, + Some(v) => v.as_array().map_or(true, |a| a.is_empty()), + }; + if is_empty { + anyhow::bail!( + "safe-outputs.submit-pr-review requires a non-empty 'allowed-events' list \ + to prevent agents from casting unrestricted review votes. Example:\n\n \ + safe-outputs:\n submit-pr-review:\n allowed-events:\n \ + - comment\n - approve-with-suggestions\n\n\ + Valid events: approve, approve-with-suggestions, request-changes, comment\n" + ); + } + } else { + anyhow::bail!( + "safe-outputs.submit-pr-review must be a configuration object with an \ + 'allowed-events' list. Example:\n\n \ + safe-outputs:\n submit-pr-review:\n allowed-events:\n - comment\n" + ); + } + } + Ok(()) +} + +/// Validate that update-pr has a required `allowed-votes` field when the `vote` operation +/// is enabled (i.e., `allowed-operations` is empty — meaning all ops — or explicitly contains +/// "vote"). +/// +/// An empty `allowed-votes` list when vote is enabled would always fail at Stage 2 with a +/// runtime error. Catching this at compile time is consistent with how +/// `validate_submit_pr_review_events` handles the analogous case. +pub fn validate_update_pr_votes(front_matter: &FrontMatter) -> Result<()> { + if let Some(config_value) = front_matter.safe_outputs.get("update-pr") { + if let Some(obj) = config_value.as_object() { + // Determine whether the vote operation is reachable: + // - allowed-operations absent or empty → all operations allowed (includes vote) + // - allowed-operations non-empty → vote is allowed only if explicitly listed + let vote_reachable = match obj.get("allowed-operations") { + None => true, + Some(v) => v + .as_array() + .map_or(true, |a| a.is_empty() || a.iter().any(|x| x == "vote")), + }; + + if vote_reachable { + let allowed_votes_empty = match obj.get("allowed-votes") { + None => true, + Some(v) => v.as_array().map_or(true, |a| a.is_empty()), + }; + if allowed_votes_empty { + anyhow::bail!( + "safe-outputs.update-pr enables the 'vote' operation but has no \ + 'allowed-votes' list. This would reject all votes at Stage 2. \ + Either restrict 'allowed-operations' to exclude 'vote', or add an \ + explicit 'allowed-votes' list:\n\n \ + safe-outputs:\n update-pr:\n allowed-votes:\n \ + - approve-with-suggestions\n - wait-for-author\n\n\ + Valid votes: approve, approve-with-suggestions, reject, \ + wait-for-author, reset\n" + ); + } + } + } + // If the value is a scalar (e.g. `update-pr: true`) we don't error here — + // the config will default to empty allowed-votes, which is safe (vote always rejected). + } + Ok(()) +} + +/// Validate that resolve-pr-review-thread has a required `allowed-statuses` field when configured. +/// +/// An empty or missing `allowed-statuses` list would let agents set any thread status, +/// including "fixed" or "wontFix" on security-critical review threads. Operators must +/// explicitly opt in to each allowed status transition. +pub fn validate_resolve_pr_thread_statuses(front_matter: &FrontMatter) -> Result<()> { + if let Some(config_value) = front_matter.safe_outputs.get("resolve-pr-review-thread") { + if let Some(obj) = config_value.as_object() { + let allowed_statuses = obj.get("allowed-statuses"); + let is_empty = match allowed_statuses { + None => true, + Some(v) => v.as_array().map_or(true, |a| a.is_empty()), + }; + if is_empty { + anyhow::bail!( + "safe-outputs.resolve-pr-review-thread requires a non-empty \ + 'allowed-statuses' list to prevent agents from manipulating thread \ + statuses without explicit operator consent. Example:\n\n \ + safe-outputs:\n resolve-pr-review-thread:\n allowed-statuses:\n\ + \x20 - fixed\n\n\ + Valid statuses: active, fixed, wont-fix, closed, by-design\n" + ); + } + } else { + anyhow::bail!( + "safe-outputs.resolve-pr-review-thread must be a configuration object \ + with an 'allowed-statuses' list. Example:\n\n \ + safe-outputs:\n resolve-pr-review-thread:\n allowed-statuses:\n\ + \x20 - fixed\n" + ); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -762,7 +1001,7 @@ mod tests { } #[test] - fn test_copilot_params_custom_mcp_not_in_params() { + fn test_copilot_params_custom_mcp_no_mcp_flag() { let mut fm = minimal_front_matter(); fm.mcp_servers.insert( "my-tool".to_string(), @@ -772,20 +1011,107 @@ mod tests { }), ); let params = generate_copilot_params(&fm); - // MCPs are handled by MCPG, not copilot CLI params - assert!(!params.contains("my-tool")); + assert!(!params.contains("--mcp my-tool")); } #[test] - fn test_copilot_params_no_mcp_flags() { + fn test_copilot_params_builtin_mcp_no_mcp_flag() { let mut fm = minimal_front_matter(); fm.mcp_servers .insert("ado".to_string(), McpConfig::Enabled(true)); let params = generate_copilot_params(&fm); - // No --mcp or --disable-mcp-server flags — MCPs are handled by MCPG - assert!(!params.contains("--mcp")); - assert!(!params.contains("--disable-mcp-server")); - assert!(!params.contains("--disable-builtin-mcps")); + // Copilot CLI has no built-in MCPs — all MCPs are handled via the MCP firewall + assert!(!params.contains("--mcp ado")); + } + + #[test] + fn test_copilot_params_max_turns_ignored() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 50\n---\n", + ) + .unwrap(); + let params = generate_copilot_params(&fm); + assert!( + !params.contains("--max-turns"), + "max-turns should not be emitted as a CLI arg" + ); + } + + #[test] + fn test_copilot_params_no_max_turns_when_simple_engine() { + let fm = minimal_front_matter(); + let params = generate_copilot_params(&fm); + assert!(!params.contains("--max-turns")); + } + + #[test] + fn test_copilot_params_no_max_timeout() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 30\n---\n", + ) + .unwrap(); + let params = generate_copilot_params(&fm); + assert!( + !params.contains("--max-timeout"), + "timeout-minutes should not be emitted as a CLI arg" + ); + } + + #[test] + fn test_copilot_params_no_max_timeout_when_simple_engine() { + let fm = minimal_front_matter(); + let params = generate_copilot_params(&fm); + assert!(!params.contains("--max-timeout")); + } + + #[test] + fn test_copilot_params_max_turns_zero_not_emitted() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 0\n---\n", + ) + .unwrap(); + let params = generate_copilot_params(&fm); + assert!( + !params.contains("--max-turns"), + "max-turns should not be emitted as a CLI arg" + ); + } + + #[test] + fn test_copilot_params_max_timeout_zero_not_emitted() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 0\n---\n", + ) + .unwrap(); + let params = generate_copilot_params(&fm); + assert!( + !params.contains("--max-timeout"), + "timeout-minutes should not be emitted as a CLI arg" + ); + } + + #[test] + fn test_job_timeout_with_value() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 30\n---\n", + ) + .unwrap(); + assert_eq!(generate_job_timeout(&fm), "timeoutInMinutes: 30"); + } + + #[test] + fn test_job_timeout_without_value() { + let fm = minimal_front_matter(); + assert_eq!(generate_job_timeout(&fm), ""); + } + + #[test] + fn test_job_timeout_zero() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 0\n---\n", + ) + .unwrap(); + assert_eq!(generate_job_timeout(&fm), "timeoutInMinutes: 0"); } // ─── sanitize_filename ──────────────────────────────────────────────────── @@ -1025,4 +1351,303 @@ mod tests { .expect("Should parse header with escaped quotes"); assert_eq!(meta.source, r#"agents/my "agent".md"#); } + + #[test] + fn test_generate_header_comment_strips_dot_slash_prefixes() { + let path = std::path::Path::new("././././agents/release-readiness.md"); + let header = generate_header_comment(path); + assert!( + header.contains(r#"source="agents/release-readiness.md""#), + "Redundant ./ prefixes should be stripped: {}", + header + ); + } + + #[test] + fn test_generate_header_comment_strips_single_dot_slash() { + let path = std::path::Path::new("./agents/my-agent.md"); + let header = generate_header_comment(path); + assert!( + header.contains(r#"source="agents/my-agent.md""#), + "Single ./ prefix should be stripped: {}", + header + ); + } + + // ─── generate_source_path ──────────────────────────────────────────────── + + #[test] + fn test_generate_source_path_preserves_directory() { + // Compiling agents/ctf.md should produce {{ workspace }}/agents/ctf.md, + // not {{ workspace }}/agents/ctf.md with a hardcoded agents/ prefix. + let path = std::path::Path::new("agents/ctf.md"); + let result = generate_source_path(path); + assert_eq!(result, "{{ workspace }}/agents/ctf.md"); + } + + #[test] + fn test_generate_source_path_nested_directory() { + let path = std::path::Path::new("pipelines/production/review.md"); + let result = generate_source_path(path); + assert_eq!(result, "{{ workspace }}/pipelines/production/review.md"); + } + + #[test] + fn test_generate_source_path_strips_dot_slash() { + let path = std::path::Path::new("./agents/my-agent.md"); + let result = generate_source_path(path); + assert_eq!(result, "{{ workspace }}/agents/my-agent.md"); + } + + #[test] + fn test_generate_source_path_filename_only() { + let path = std::path::Path::new("my-agent.md"); + let result = generate_source_path(path); + assert_eq!(result, "{{ workspace }}/my-agent.md"); + } + + // ─── generate_pipeline_path ────────────────────────────────────────────── + + #[test] + fn test_generate_pipeline_path_preserves_directory() { + // The original bug: compiling agents/ctf.md produced agents/ctf.yml as + // output, but the embedded path was only ctf.yml (missing agents/). + let path = std::path::Path::new("agents/ctf.yml"); + let result = generate_pipeline_path(path); + assert_eq!(result, "{{ workspace }}/agents/ctf.yml"); + } + + #[test] + fn test_generate_pipeline_path_nested_directory() { + let path = std::path::Path::new("pipelines/production/review.yml"); + let result = generate_pipeline_path(path); + assert_eq!(result, "{{ workspace }}/pipelines/production/review.yml"); + } + + #[test] + fn test_generate_pipeline_path_strips_dot_slash() { + let path = std::path::Path::new("./agents/my-agent.yml"); + let result = generate_pipeline_path(path); + assert_eq!(result, "{{ workspace }}/agents/my-agent.yml"); + } + + #[test] + fn test_generate_pipeline_path_filename_only() { + let path = std::path::Path::new("pipeline.yml"); + let result = generate_pipeline_path(path); + assert_eq!(result, "{{ workspace }}/pipeline.yml"); + } + + #[test] + fn test_generate_source_path_absolute_falls_back_to_filename() { + // An absolute path that is NOT inside a git repo should fall back + // to filename-only to avoid embedding a machine-specific absolute path. + // Use a real temp dir so the path is genuinely absolute on any OS. + let tmp = tempfile::TempDir::new().unwrap(); + let abs_path = tmp.path().join("agents").join("ctf.md"); + // No .git marker — find_git_root will walk up and find nothing + // (temp dirs are outside any repo). + let result = generate_source_path(&abs_path); + assert_eq!(result, "{{ workspace }}/ctf.md"); + } + + #[test] + fn test_generate_pipeline_path_absolute_falls_back_to_filename() { + let tmp = tempfile::TempDir::new().unwrap(); + let abs_path = tmp.path().join("agents").join("ctf.yml"); + let result = generate_pipeline_path(&abs_path); + assert_eq!(result, "{{ workspace }}/ctf.yml"); + } + + #[test] + fn test_generate_source_path_absolute_with_git_root_preserves_directory() { + // When the absolute path is inside a git repo, the directory structure + // relative to the repo root must be preserved. + use std::fs; + let tmp = tempfile::TempDir::new().unwrap(); + let agents_dir = tmp.path().join("agents"); + fs::create_dir_all(&agents_dir).unwrap(); + // A `.git` file (as used in worktrees) satisfies `.exists()` just like + // a `.git` directory, so either form is a valid marker. + fs::write(tmp.path().join(".git"), "gitdir: fake").unwrap(); + let abs_path = agents_dir.join("ctf.md"); + let result = generate_source_path(&abs_path); + assert_eq!(result, "{{ workspace }}/agents/ctf.md"); + } + + #[test] + fn test_generate_pipeline_path_absolute_with_git_root_preserves_directory() { + use std::fs; + let tmp = tempfile::TempDir::new().unwrap(); + let agents_dir = tmp.path().join("agents"); + fs::create_dir_all(&agents_dir).unwrap(); + fs::write(tmp.path().join(".git"), "gitdir: fake").unwrap(); + let abs_path = agents_dir.join("ctf.yml"); + let result = generate_pipeline_path(&abs_path); + assert_eq!(result, "{{ workspace }}/agents/ctf.yml"); + } + + // ─── validate_submit_pr_review_events ──────────────────────────────────── + + #[test] + fn test_submit_pr_review_events_passes_when_not_configured() { + let fm = minimal_front_matter(); + assert!(validate_submit_pr_review_events(&fm).is_ok()); + } + + #[test] + fn test_submit_pr_review_events_fails_when_allowed_events_missing() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pr-review:\n allowed-repositories:\n - self\n---\n" + ).unwrap(); + let result = validate_submit_pr_review_events(&fm); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("allowed-events"), "message: {msg}"); + } + + #[test] + fn test_submit_pr_review_events_fails_when_allowed_events_empty() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pr-review:\n allowed-events: []\n---\n" + ).unwrap(); + let result = validate_submit_pr_review_events(&fm); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("allowed-events"), "message: {msg}"); + } + + #[test] + fn test_submit_pr_review_events_fails_when_value_is_scalar() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pr-review: true\n---\n", + ) + .unwrap(); + let result = validate_submit_pr_review_events(&fm); + assert!(result.is_err()); + } + + #[test] + fn test_submit_pr_review_events_passes_when_events_provided() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pr-review:\n allowed-events:\n - comment\n - approve\n---\n" + ).unwrap(); + assert!(validate_submit_pr_review_events(&fm).is_ok()); + } + + // ─── validate_update_pr_votes ───────────────────────────────────────────── + + #[test] + fn test_update_pr_votes_passes_when_not_configured() { + let fm = minimal_front_matter(); + assert!(validate_update_pr_votes(&fm).is_ok()); + } + + #[test] + fn test_update_pr_votes_fails_when_vote_reachable_and_no_allowed_votes() { + // allowed-operations absent → vote is reachable; no allowed-votes → should fail + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-repositories:\n - self\n---\n" + ).unwrap(); + let result = validate_update_pr_votes(&fm); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("allowed-votes"), "message: {msg}"); + } + + #[test] + fn test_update_pr_votes_fails_when_vote_explicit_and_no_allowed_votes() { + // allowed-operations contains "vote"; no allowed-votes → should fail + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-operations:\n - vote\n---\n" + ).unwrap(); + let result = validate_update_pr_votes(&fm); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("allowed-votes"), "message: {msg}"); + } + + #[test] + fn test_update_pr_votes_fails_when_allowed_votes_empty() { + // allowed-operations absent; allowed-votes is empty list → should fail + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-votes: []\n---\n" + ).unwrap(); + let result = validate_update_pr_votes(&fm); + assert!(result.is_err()); + } + + #[test] + fn test_update_pr_votes_passes_when_vote_excluded_from_allowed_operations() { + // allowed-operations is non-empty and does not contain "vote" → safe, no error + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-operations:\n - add-reviewers\n - set-auto-complete\n---\n" + ).unwrap(); + assert!(validate_update_pr_votes(&fm).is_ok()); + } + + #[test] + fn test_update_pr_votes_passes_when_vote_reachable_and_allowed_votes_set() { + // allowed-operations absent; allowed-votes non-empty → OK + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-votes:\n - approve-with-suggestions\n---\n" + ).unwrap(); + assert!(validate_update_pr_votes(&fm).is_ok()); + } + + #[test] + fn test_update_pr_votes_passes_when_vote_explicit_and_allowed_votes_set() { + // allowed-operations contains "vote"; allowed-votes non-empty → OK + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-operations:\n - vote\n allowed-votes:\n - wait-for-author\n---\n" + ).unwrap(); + assert!(validate_update_pr_votes(&fm).is_ok()); + } + + // ─── validate_resolve_pr_thread_statuses ────────────────────────────────── + + #[test] + fn test_resolve_pr_thread_passes_when_not_configured() { + let fm = minimal_front_matter(); + assert!(validate_resolve_pr_thread_statuses(&fm).is_ok()); + } + + #[test] + fn test_resolve_pr_thread_fails_when_allowed_statuses_missing() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pr-review-thread:\n allowed-repositories:\n - self\n---\n" + ).unwrap(); + let result = validate_resolve_pr_thread_statuses(&fm); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("allowed-statuses"), "message: {msg}"); + } + + #[test] + fn test_resolve_pr_thread_fails_when_allowed_statuses_empty() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pr-review-thread:\n allowed-statuses: []\n---\n" + ).unwrap(); + let result = validate_resolve_pr_thread_statuses(&fm); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("allowed-statuses"), "message: {msg}"); + } + + #[test] + fn test_resolve_pr_thread_fails_when_value_is_scalar() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pr-review-thread: true\n---\n" + ).unwrap(); + let result = validate_resolve_pr_thread_statuses(&fm); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_pr_thread_passes_when_statuses_provided() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pr-review-thread:\n allowed-statuses:\n - fixed\n - wont-fix\n---\n" + ).unwrap(); + assert!(validate_resolve_pr_thread_statuses(&fm).is_ok()); + } } diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 75b436d..e545c6e 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -182,21 +182,66 @@ pub async fn compile_all_pipelines() -> Result<()> { /// Check that a compiled pipeline YAML matches its source markdown. /// -/// Compiles the source markdown fresh and compares (whitespace-normalized) +/// Reads the `@ado-aw` header from `pipeline_path` to discover the source +/// markdown file, compiles it fresh, and compares (whitespace-normalized) /// against the existing pipeline file. Returns an error if they differ. -pub async fn check_pipeline(source_path: &str, pipeline_path: &str) -> Result<()> { - let source_path = Path::new(source_path); +pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { let pipeline_path = Path::new(pipeline_path); + + // Read existing pipeline and extract header to discover source path + let existing = tokio::fs::read_to_string(pipeline_path) + .await + .with_context(|| { + format!( + "Failed to read pipeline file: {}", + pipeline_path.display() + ) + })?; + + let header_meta = existing + .lines() + .take(5) + .find_map(|line| crate::detect::parse_header_line(line)) + .with_context(|| { + format!( + "No @ado-aw header found in {}. Is this file generated by ado-aw?", + pipeline_path.display() + ) + })?; + + // The header stores the source path relative to the repository root. + // Walk up from the pipeline file to find the .git directory, then resolve + // the source path relative to that root. + let pipeline_abs = if pipeline_path.is_absolute() { + pipeline_path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(pipeline_path) + }; + let repo_root = find_repo_root(&pipeline_abs).with_context(|| { + format!( + "Could not find repository root (no .git directory) from {}", + pipeline_path.display() + ) + })?; + let source_path = repo_root.join(&header_meta.source); + info!( - "Checking pipeline integrity: {} -> {}", + "Checking pipeline integrity: {} -> {} (source from header)", source_path.display(), pipeline_path.display() ); // Compile fresh from source - let content = tokio::fs::read_to_string(source_path) + let content = tokio::fs::read_to_string(&source_path) .await - .with_context(|| format!("Failed to read source file: {}", source_path.display()))?; + .with_context(|| { + format!( + "Source file '{}' (from header) not found. Has it been moved or deleted?", + source_path.display() + ) + })?; let (front_matter, markdown_body) = parse_markdown(&content)?; @@ -207,21 +252,18 @@ pub async fn check_pipeline(source_path: &str, pipeline_path: &str) -> Result<() CompileTarget::Standalone => Box::new(standalone::StandaloneCompiler), }; + // Pass the header's relative source path to compile so the generated + // header embeds the same path that was used during the original compilation. let pipeline_yaml = compiler - .compile(source_path, pipeline_path, &front_matter, &markdown_body) + .compile( + Path::new(&header_meta.source), + pipeline_path, + &front_matter, + &markdown_body, + ) .await?; let pipeline_yaml = clean_generated_yaml(&pipeline_yaml); - // Read existing pipeline file - let existing = tokio::fs::read_to_string(pipeline_path) - .await - .with_context(|| { - format!( - "Failed to read pipeline file: {}", - pipeline_path.display() - ) - })?; - // Compare ignoring whitespace differences if normalize_whitespace(&pipeline_yaml) != normalize_whitespace(&existing) { anyhow::bail!( @@ -236,6 +278,19 @@ pub async fn check_pipeline(source_path: &str, pipeline_path: &str) -> Result<() Ok(()) } +/// Walk up from `start` to find the nearest directory containing `.git`. +fn find_repo_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if current.join(".git").exists() { + return Some(current); + } + if !current.pop() { + return None; + } + } +} + /// Normalize a string by removing all whitespace characters. /// /// Used for integrity checks so that formatting-only differences diff --git a/src/compile/onees.rs b/src/compile/onees.rs index 902b40a..ad0cf94 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -17,13 +17,15 @@ use std::path::Path; use super::Compiler; use super::common::{ - self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, compute_effective_workspace, generate_copilot_params, + self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, compute_effective_workspace, generate_acquire_ado_token, generate_checkout_self, generate_checkout_steps, - generate_ci_trigger, generate_copilot_ado_env, generate_executor_ado_env, - generate_header_comment, generate_pipeline_path, generate_pipeline_resources, - generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path, - generate_working_directory, is_custom_mcp, replace_with_indent, validate_comment_target, - validate_update_work_item_target, validate_write_permissions, + generate_ci_trigger, generate_copilot_ado_env, generate_copilot_params, + generate_executor_ado_env, generate_header_comment, generate_job_timeout, + generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger, + generate_repositories, generate_schedule, generate_source_path, generate_working_directory, + is_custom_mcp, replace_with_indent, validate_comment_target, + validate_resolve_pr_thread_statuses, validate_submit_pr_review_events, + validate_update_pr_votes, validate_update_work_item_target, validate_write_permissions, }; use super::types::{FrontMatter, McpConfig}; @@ -58,7 +60,7 @@ impl Compiler for OneESCompiler { let repositories = generate_repositories(&front_matter.repositories); let checkout_steps = generate_checkout_steps(&front_matter.checkout); let checkout_self = generate_checkout_self(); - let agency_params = generate_copilot_params(front_matter); + let copilot_params = generate_copilot_params(front_matter); let effective_workspace = compute_effective_workspace( &front_matter.workspace, @@ -104,6 +106,7 @@ displayName: "Finalize""#, } else { String::new() }; + let job_timeout = generate_job_timeout(front_matter); // Load threat analysis prompt template let threat_analysis_prompt = include_str!("../../templates/threat-analysis.md"); @@ -117,18 +120,30 @@ displayName: "Finalize""#, // Generate service connection token acquisition steps and env vars let acquire_read_token = generate_acquire_ado_token( - front_matter.permissions.as_ref().and_then(|p| p.read.as_deref()), + front_matter + .permissions + .as_ref() + .and_then(|p| p.read.as_deref()), "SC_READ_TOKEN", ); let copilot_ado_env = generate_copilot_ado_env( - front_matter.permissions.as_ref().and_then(|p| p.read.as_deref()), + front_matter + .permissions + .as_ref() + .and_then(|p| p.read.as_deref()), ); let acquire_write_token = generate_acquire_ado_token( - front_matter.permissions.as_ref().and_then(|p| p.write.as_deref()), + front_matter + .permissions + .as_ref() + .and_then(|p| p.write.as_deref()), "SC_WRITE_TOKEN", ); let executor_ado_env = generate_executor_ado_env( - front_matter.permissions.as_ref().and_then(|p| p.write.as_deref()), + front_matter + .permissions + .as_ref() + .and_then(|p| p.write.as_deref()), ); // Validate that write-requiring safe-outputs have a write service connection @@ -137,6 +152,12 @@ displayName: "Finalize""#, validate_comment_target(front_matter)?; // Validate update-work-item has required target field validate_update_work_item_target(front_matter)?; + // Validate submit-pr-review has required allowed-events field + validate_submit_pr_review_events(front_matter)?; + // Validate update-pr vote operation has required allowed-votes field + validate_update_pr_votes(front_matter)?; + // Validate resolve-pr-review-thread has required allowed-statuses field + validate_resolve_pr_thread_statuses(front_matter)?; // Replace all template markers let compiler_version = env!("CARGO_PKG_VERSION"); @@ -163,13 +184,14 @@ displayName: "Finalize""#, ("{{ log_level }}", ""), ("{{ mcp_configuration }}", &mcp_configuration), ("{{ agentic_depends_on }}", &agentic_depends_on), + ("{{ job_timeout }}", &job_timeout), ("{{ setup_job }}", &setup_job), ("{{ teardown_job }}", &teardown_job), ("{{ source_path }}", &source_path), ("{{ pipeline_path }}", &pipeline_path), ("{{ working_directory }}", &working_directory), ("{{ workspace }}", &working_directory), - ("{{ agency_params }}", &agency_params), + ("{{ copilot_params }}", &copilot_params), ("{{ acquire_ado_token }}", &acquire_read_token), ("{{ copilot_ado_env }}", &copilot_ado_env), ("{{ acquire_write_token }}", &acquire_write_token), diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 5336f62..7867c5a 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -15,13 +15,14 @@ use std::path::Path; use super::Compiler; use super::common::{ self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, MCPG_PORT, MCPG_VERSION, - compute_effective_workspace, generate_copilot_params, generate_acquire_ado_token, - generate_cancel_previous_builds, generate_checkout_self, generate_checkout_steps, - generate_ci_trigger, generate_copilot_ado_env, generate_executor_ado_env, - generate_header_comment, generate_pipeline_path, generate_pipeline_resources, - generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path, - generate_working_directory, replace_with_indent, sanitize_filename, - validate_write_permissions, validate_comment_target, validate_update_work_item_target, + compute_effective_workspace, generate_acquire_ado_token, generate_cancel_previous_builds, + generate_checkout_self, generate_checkout_steps, generate_ci_trigger, generate_copilot_ado_env, + generate_copilot_params, generate_executor_ado_env, generate_header_comment, + generate_job_timeout, generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger, + generate_repositories, generate_schedule, generate_source_path, generate_working_directory, + replace_with_indent, sanitize_filename, validate_comment_target, + validate_resolve_pr_thread_statuses, validate_submit_pr_review_events, + validate_update_pr_votes, validate_update_work_item_target, validate_write_permissions, }; use super::types::{FrontMatter, McpConfig}; use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts}; @@ -59,7 +60,7 @@ impl Compiler for StandaloneCompiler { let repositories = generate_repositories(&front_matter.repositories); let checkout_steps = generate_checkout_steps(&front_matter.checkout); let checkout_self = generate_checkout_self(); - let agency_params = generate_copilot_params(front_matter); + let copilot_params = generate_copilot_params(front_matter); let agent_name = sanitize_filename(&front_matter.name); // Compute effective workspace @@ -92,35 +93,40 @@ impl Compiler for StandaloneCompiler { .unwrap_or_else(|| DEFAULT_POOL.to_string()); // Generate hooks - let setup_job = generate_setup_job( - &front_matter.setup, - &front_matter.name, - &pool, - ); - let teardown_job = generate_teardown_job( - &front_matter.teardown, - &front_matter.name, - &pool, - ); + let setup_job = generate_setup_job(&front_matter.setup, &front_matter.name, &pool); + let teardown_job = generate_teardown_job(&front_matter.teardown, &front_matter.name, &pool); let has_memory = front_matter.safe_outputs.contains_key("memory"); let prepare_steps = generate_prepare_steps(&front_matter.steps, has_memory); let finalize_steps = generate_finalize_steps(&front_matter.post_steps); let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup); + let job_timeout = generate_job_timeout(front_matter); // Generate service connection token acquisition steps and env vars let acquire_read_token = generate_acquire_ado_token( - front_matter.permissions.as_ref().and_then(|p| p.read.as_deref()), + front_matter + .permissions + .as_ref() + .and_then(|p| p.read.as_deref()), "SC_READ_TOKEN", ); let copilot_ado_env = generate_copilot_ado_env( - front_matter.permissions.as_ref().and_then(|p| p.read.as_deref()), + front_matter + .permissions + .as_ref() + .and_then(|p| p.read.as_deref()), ); let acquire_write_token = generate_acquire_ado_token( - front_matter.permissions.as_ref().and_then(|p| p.write.as_deref()), + front_matter + .permissions + .as_ref() + .and_then(|p| p.write.as_deref()), "SC_WRITE_TOKEN", ); let executor_ado_env = generate_executor_ado_env( - front_matter.permissions.as_ref().and_then(|p| p.write.as_deref()), + front_matter + .permissions + .as_ref() + .and_then(|p| p.write.as_deref()), ); // Validate that write-requiring safe-outputs have a write service connection @@ -129,6 +135,12 @@ impl Compiler for StandaloneCompiler { validate_comment_target(front_matter)?; // Validate update-work-item has required target field validate_update_work_item_target(front_matter)?; + // Validate submit-pr-review has required allowed-events field + validate_submit_pr_review_events(front_matter)?; + // Validate update-pr vote operation has required allowed-votes field + validate_update_pr_votes(front_matter)?; + // Validate resolve-pr-review-thread has required allowed-statuses field + validate_resolve_pr_thread_statuses(front_matter)?; // Load threat analysis prompt template let threat_analysis_prompt = include_str!("../../templates/threat-analysis.md"); @@ -153,6 +165,7 @@ impl Compiler for StandaloneCompiler { ("{{ prepare_steps }}", &prepare_steps), ("{{ finalize_steps }}", &finalize_steps), ("{{ agentic_depends_on }}", &agentic_depends_on), + ("{{ job_timeout }}", &job_timeout), ("{{ repositories }}", &repositories), ("{{ schedule }}", &schedule), ("{{ pipeline_resources }}", &pipeline_resources), @@ -164,7 +177,7 @@ impl Compiler for StandaloneCompiler { ("{{ agent }}", &agent_name), ("{{ agent_name }}", &front_matter.name), ("{{ agent_description }}", &front_matter.description), - ("{{ agency_params }}", &agency_params), + ("{{ copilot_params }}", &copilot_params), ("{{ source_path }}", &source_path), ("{{ pipeline_path }}", &pipeline_path), ("{{ working_directory }}", &working_directory), @@ -186,14 +199,11 @@ impl Compiler for StandaloneCompiler { // Always generate MCPG config — safeoutputs is always required regardless // of whether additional mcp-servers are configured in front matter. let config = generate_mcpg_config(front_matter); - let mcpg_config_json = serde_json::to_string_pretty(&config) - .context("Failed to serialize MCPG config")?; + let mcpg_config_json = + serde_json::to_string_pretty(&config).context("Failed to serialize MCPG config")?; - let pipeline_yaml = replace_with_indent( - &pipeline_yaml, - "{{ mcpg_config }}", - &mcpg_config_json, - ); + let pipeline_yaml = + replace_with_indent(&pipeline_yaml, "{{ mcpg_config }}", &mcpg_config_json); // Prepend header comment for pipeline detection let header = generate_header_comment(input_path); @@ -277,11 +287,7 @@ fn generate_allowed_domains(front_matter: &FrontMatter) -> String { } /// Generate the setup job YAML -fn generate_setup_job( - setup_steps: &[serde_yaml::Value], - agent_name: &str, - pool: &str, -) -> String { +fn generate_setup_job(setup_steps: &[serde_yaml::Value], agent_name: &str, pool: &str) -> String { if setup_steps.is_empty() { return String::new(); } @@ -484,19 +490,11 @@ pub fn generate_mcpg_config(front_matter: &FrontMatter) -> McpgConfig { }, ); } else { - log::warn!( - "MCP '{}' has options but no command — skipping. \ - All MCPs now require an explicit command: field.", - name - ); + log::warn!("MCP '{}' has no command - skipping", name); + continue; } } else { - log::warn!( - "MCP '{}' specified as boolean true — skipping. \ - Boolean-enabled MCPs require built-in MCPs which no longer exist. \ - Use the object form with a command: field instead.", - name - ); + log::warn!("MCP '{}' has no command - skipping", name); } } @@ -576,16 +574,7 @@ mod tests { } #[test] - fn test_generate_mcpg_config_always_includes_safeoutputs() { - let fm = minimal_front_matter(); - let config = generate_mcpg_config(&fm); - let so = config.mcp_servers.get("safeoutputs").unwrap(); - assert_eq!(so.server_type, "http"); - assert!(so.url.as_ref().unwrap().contains("localhost")); - } - - #[test] - fn test_generate_mcpg_config_custom_mcp() { + fn test_generate_firewall_config_custom_mcp() { let mut fm = minimal_front_matter(); fm.mcp_servers.insert( "my-tool".to_string(), @@ -660,20 +649,25 @@ mod tests { }), ); let config = generate_mcpg_config(&fm); - let json = serde_json::to_string_pretty(&config) - .expect("Config should serialize to JSON"); + let json = serde_json::to_string_pretty(&config).expect("Config should serialize to JSON"); let parsed: serde_json::Value = serde_json::from_str(&json).expect("Serialized JSON should parse back"); // Verify top-level structure matches MCPG expectation - assert!(parsed.get("mcpServers").is_some(), "Should have mcpServers key"); + assert!( + parsed.get("mcpServers").is_some(), + "Should have mcpServers key" + ); assert!(parsed.get("gateway").is_some(), "Should have gateway key"); let gw = parsed.get("gateway").unwrap(); assert!(gw.get("port").is_some(), "Gateway should have port"); assert!(gw.get("domain").is_some(), "Gateway should have domain"); assert!(gw.get("apiKey").is_some(), "Gateway should have apiKey"); - assert!(gw.get("payloadDir").is_some(), "Gateway should have payloadDir"); + assert!( + gw.get("payloadDir").is_some(), + "Gateway should have payloadDir" + ); } #[test] @@ -729,7 +723,8 @@ mod tests { } #[test] - fn test_generate_mcpg_config_custom_mcp_with_env() { + fn test_generate_firewall_config_unknown_non_builtin_skipped() { + // An MCP with no command should be skipped let mut fm = minimal_front_matter(); let mut env = std::collections::HashMap::new(); env.insert("TOKEN".to_string(), "secret".to_string()); @@ -764,8 +759,14 @@ mod tests { let config = generate_mcpg_config(&fm); // The reserved entry should still be the HTTP backend, not the user's command let so = config.mcp_servers.get("safeoutputs").unwrap(); - assert_eq!(so.server_type, "http", "safeoutputs should remain HTTP backend"); - assert!(so.command.is_none(), "User command should not overwrite safeoutputs"); + assert_eq!( + so.server_type, "http", + "safeoutputs should remain HTTP backend" + ); + assert!( + so.command.is_none(), + "User command should not overwrite safeoutputs" + ); } #[test] diff --git a/src/compile/types.rs b/src/compile/types.rs index 89ecca8..1b43839 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -129,7 +129,6 @@ pub struct ScheduleOptions { /// # Object format (with additional options) /// engine: /// model: claude-opus-4.5 -/// max-turns: 50 /// timeout-minutes: 30 /// ``` #[derive(Debug, Deserialize, Clone)] @@ -156,7 +155,7 @@ impl EngineConfig { } } - /// Get the max turns setting + /// Get the max turns setting (deprecated — ignored at compile time) pub fn max_turns(&self) -> Option { match self { EngineConfig::Simple(_) => None, @@ -178,7 +177,7 @@ pub struct EngineOptions { /// AI model to use (defaults to claude-opus-4.5) #[serde(default)] pub model: Option, - /// Maximum number of chat iterations per run + /// Maximum number of chat iterations per run (deprecated — not supported by Copilot CLI) #[serde(default, rename = "max-turns")] pub max_turns: Option, /// Workflow timeout in minutes diff --git a/src/execute.rs b/src/execute.rs index b59c530..9a7bea8 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -10,10 +10,14 @@ use std::collections::HashMap; use std::path::Path; use crate::ndjson::{self, SAFE_OUTPUT_FILENAME}; +use crate::sanitize::Sanitize; use crate::tools::{ + AddBuildTagResult, AddPrCommentResult, CreateBranchResult, CreateGitTagResult, CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, CommentOnWorkItemResult, - ExecutionContext, ExecutionResult, Executor, ToolResult, - UpdateWikiPageResult, UpdateWorkItemResult, + ExecutionContext, ExecutionResult, Executor, LinkWorkItemsResult, QueueBuildResult, + ReplyToPrCommentResult, ReportIncompleteResult, ResolvePrThreadResult, SubmitPrReviewResult, + ToolResult, UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, + UploadAttachmentResult, }; // Re-export memory types for use by main.rs @@ -113,6 +117,17 @@ pub async fn execute_safe_outputs( CommentOnWorkItemResult, CreateWikiPageResult, UpdateWikiPageResult, + AddPrCommentResult, + LinkWorkItemsResult, + QueueBuildResult, + CreateGitTagResult, + AddBuildTagResult, + CreateBranchResult, + UpdatePrResult, + UploadAttachmentResult, + SubmitPrReviewResult, + ReplyToPrCommentResult, + ResolvePrThreadResult, ); let mut results = Vec::new(); @@ -264,10 +279,116 @@ pub async fn execute_safe_output( ); output.execute_sanitized(ctx).await? } + "add-pr-comment" => { + debug!("Parsing add-pr-comment payload"); + let mut output: AddPrCommentResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse add-pr-comment: {}", e))?; + debug!( + "add-pr-comment: pr_id={}, content length={}", + output.pull_request_id, + output.content.len() + ); + output.execute_sanitized(ctx).await? + } + "link-work-items" => { + debug!("Parsing link-work-items payload"); + let mut output: LinkWorkItemsResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse link-work-items: {}", e))?; + debug!( + "link-work-items: source={}, target={}, type={}", + output.source_id, output.target_id, output.link_type + ); + output.execute_sanitized(ctx).await? + } + "queue-build" => { + debug!("Parsing queue-build payload"); + let mut output: QueueBuildResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse queue-build: {}", e))?; + debug!("queue-build: pipeline_id={}", output.pipeline_id); + output.execute_sanitized(ctx).await? + } + "create-git-tag" => { + debug!("Parsing create-git-tag payload"); + let mut output: CreateGitTagResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse create-git-tag: {}", e))?; + debug!("create-git-tag: tag_name='{}'", output.tag_name); + output.execute_sanitized(ctx).await? + } + "add-build-tag" => { + debug!("Parsing add-build-tag payload"); + let mut output: AddBuildTagResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse add-build-tag: {}", e))?; + debug!("add-build-tag: build_id={}, tag='{}'", output.build_id, output.tag); + output.execute_sanitized(ctx).await? + } + "create-branch" => { + debug!("Parsing create-branch payload"); + let mut output: CreateBranchResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse create-branch: {}", e))?; + debug!("create-branch: branch_name='{}'", output.branch_name); + output.execute_sanitized(ctx).await? + } + "update-pr" => { + debug!("Parsing update-pr payload"); + let mut output: UpdatePrResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse update-pr: {}", e))?; + debug!( + "update-pr: pr_id={}, operation='{}'", + output.pull_request_id, output.operation + ); + output.execute_sanitized(ctx).await? + } + "upload-attachment" => { + debug!("Parsing upload-attachment payload"); + let mut output: UploadAttachmentResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse upload-attachment: {}", e))?; + debug!( + "upload-attachment: work_item_id={}, file_path='{}'", + output.work_item_id, output.file_path + ); + output.execute_sanitized(ctx).await? + } + "submit-pr-review" => { + debug!("Parsing submit-pr-review payload"); + let mut output: SubmitPrReviewResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse submit-pr-review: {}", e))?; + debug!( + "submit-pr-review: pr_id={}, event='{}'", + output.pull_request_id, output.event + ); + output.execute_sanitized(ctx).await? + } + "reply-to-pr-review-comment" => { + debug!("Parsing reply-to-pr-review-comment payload"); + let mut output: ReplyToPrCommentResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse reply-to-pr-review-comment: {}", e))?; + debug!( + "reply-to-pr-review-comment: pr_id={}, thread_id={}", + output.pull_request_id, output.thread_id + ); + output.execute_sanitized(ctx).await? + } + "resolve-pr-review-thread" => { + debug!("Parsing resolve-pr-review-thread payload"); + let mut output: ResolvePrThreadResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse resolve-pr-review-thread: {}", e))?; + debug!( + "resolve-pr-review-thread: pr_id={}, thread_id={}, status='{}'", + output.pull_request_id, output.thread_id, output.status + ); + output.execute_sanitized(ctx).await? + } "noop" => { debug!("Skipping noop entry"); ExecutionResult::success("Skipped informational output: noop") } + "report-incomplete" => { + let mut output: ReportIncompleteResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse report-incomplete: {}", e))?; + output.sanitize_fields(); + debug!("report-incomplete: {}", output.reason); + ExecutionResult::failure(format!("Agent reported task incomplete: {}", output.reason)) + } "missing-tool" => { debug!("Skipping missing-tool entry"); ExecutionResult::success("Skipped informational output: missing-tool") diff --git a/src/main.rs b/src/main.rs index 0007fe3..6ff98ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,9 +38,7 @@ enum Commands { }, /// Check that a compiled pipeline matches its source markdown Check { - /// Path to the source markdown file - source: String, - /// Path to the pipeline YAML file to verify + /// Path to the pipeline YAML file to verify (source auto-detected from header) pipeline: String, }, /// Run as an MCP server @@ -163,8 +161,8 @@ async fn main() -> Result<()> { compile::compile_all_pipelines().await? } }, - Commands::Check { source, pipeline } => { - compile::check_pipeline(&source, &pipeline).await?; + Commands::Check { pipeline } => { + compile::check_pipeline(&pipeline).await?; } Commands::Mcp { output_directory, diff --git a/src/mcp.rs b/src/mcp.rs index 6b39784..7b04bde 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -11,12 +11,23 @@ use std::path::PathBuf; use crate::ndjson::{self, SAFE_OUTPUT_FILENAME}; use crate::sanitize::{Sanitize, sanitize as sanitize_text}; use crate::tools::{ + AddBuildTagParams, AddBuildTagResult, + AddPrCommentParams, AddPrCommentResult, CommentOnWorkItemParams, CommentOnWorkItemResult, + CreateBranchParams, CreateBranchResult, + CreateGitTagParams, CreateGitTagResult, CreatePrParams, CreatePrResult, CreateWikiPageParams, CreateWikiPageResult, CreateWorkItemParams, CreateWorkItemResult, + LinkWorkItemsParams, LinkWorkItemsResult, + ReplyToPrCommentParams, ReplyToPrCommentResult, + ReportIncompleteParams, ReportIncompleteResult, + ResolvePrThreadParams, ResolvePrThreadResult, UpdateWikiPageParams, UpdateWikiPageResult, MissingDataParams, MissingDataResult, - MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult, + MissingToolParams, MissingToolResult, NoopParams, NoopResult, QueueBuildParams, + QueueBuildResult, SubmitPrReviewParams, SubmitPrReviewResult, ToolResult, + UpdatePrParams, UpdatePrResult, UpdateWorkItemParams, UpdateWorkItemResult, + UploadAttachmentParams, UploadAttachmentResult, anyhow_to_mcp_error, }; @@ -536,6 +547,290 @@ structured output that should be visible in the project wiki." result.path ))])) } + + #[tool( + name = "add-pr-comment", + description = "Add a comment thread to an Azure DevOps pull request. Supports both \ +general comments and file-specific inline comments with optional line positioning. \ +The comment will be posted during safe output processing." + )] + async fn add_pr_comment( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: add-pr-comment - PR #{}", + params.0.pull_request_id + ); + debug!("Content length: {} chars", params.0.content.len()); + let mut sanitized = params.0; + sanitized.content = sanitize_text(&sanitized.content); + let result: AddPrCommentResult = sanitized.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + info!("PR comment queued for PR #{}", result.pull_request_id); + Ok(CallToolResult::success(vec![Content::text(format!( + "Comment queued for PR #{}. The comment will be posted during safe output processing.", + result.pull_request_id + ))])) + } + + #[tool( + name = "link-work-items", + description = "Create a relationship link between two Azure DevOps work items. \ +Supported link types: parent, child, related, predecessor, successor, duplicate, duplicate-of. \ +The link will be created during safe output processing." + )] + async fn link_work_items( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: link-work-items - {} -> {} ({})", + params.0.source_id, params.0.target_id, params.0.link_type + ); + let mut sanitized = params.0; + sanitized.comment = sanitized.comment.map(|c| sanitize_text(&c)); + let result: LinkWorkItemsResult = sanitized.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "Link queued: work item #{} → #{} ({}). The link will be created during safe output processing.", + result.source_id, result.target_id, result.link_type + ))])) + } + + #[tool( + name = "queue-build", + description = "Trigger an Azure DevOps pipeline/build run. The pipeline must be in the \ +allowed-pipelines list configured in the pipeline definition. Optionally specify a branch \ +and template parameters." + )] + async fn queue_build( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: queue-build - pipeline {}", + params.0.pipeline_id + ); + let mut sanitized = params.0; + sanitized.reason = sanitized.reason.map(|r| sanitize_text(&r)); + let result: QueueBuildResult = sanitized.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "Build queued for pipeline {}. The build will be triggered during safe output processing.", + result.pipeline_id + ))])) + } + + #[tool( + name = "create-git-tag", + description = "Create an annotated git tag on a commit in an Azure DevOps repository. \ +The tag will be created during safe output processing." + )] + async fn create_git_tag( + &self, + params: Parameters, + ) -> Result { + info!("Tool called: create-git-tag - '{}'", params.0.tag_name); + let mut sanitized = params.0; + sanitized.message = sanitized.message.map(|m| sanitize_text(&m)); + let result: CreateGitTagResult = sanitized.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "Git tag '{}' queued. The tag will be created during safe output processing.", + result.tag_name + ))])) + } + + #[tool( + name = "add-build-tag", + description = "Add a tag to an Azure DevOps build for classification and filtering. \ +The tag will be added during safe output processing." + )] + async fn add_build_tag( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: add-build-tag - build {} tag '{}'", + params.0.build_id, params.0.tag + ); + let result: AddBuildTagResult = params.0.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "Build tag '{}' queued for build #{}. The tag will be added during safe output processing.", + result.tag, result.build_id + ))])) + } + + #[tool( + name = "create-branch", + description = "Create a new branch in an Azure DevOps repository without creating a \ +pull request. The branch will be created during safe output processing." + )] + async fn create_branch( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: create-branch - '{}'", + params.0.branch_name + ); + let result: CreateBranchResult = params.0.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "Branch '{}' queued for creation. The branch will be created during safe output processing.", + result.branch_name + ))])) + } + + #[tool( + name = "update-pr", + description = "Update pull request metadata in Azure DevOps. Supports operations: \ +add-reviewers, add-labels, set-auto-complete, vote, update-description. \ +Changes will be applied during safe output processing." + )] + async fn update_pr( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: update-pr - PR #{} operation '{}'", + params.0.pull_request_id, params.0.operation + ); + let mut sanitized = params.0; + sanitized.description = sanitized.description.map(|d| sanitize_text(&d)); + let result: UpdatePrResult = sanitized.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "PR #{} '{}' operation queued. Changes will be applied during safe output processing.", + result.pull_request_id, result.operation + ))])) + } + + #[tool( + name = "upload-attachment", + description = "Upload a file attachment to an Azure DevOps work item. The file will be \ +uploaded and linked during safe output processing. File size and type restrictions may apply." + )] + async fn upload_attachment( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: upload-attachment - work item #{} file '{}'", + params.0.work_item_id, params.0.file_path + ); + let mut sanitized = params.0; + sanitized.comment = sanitized.comment.map(|c| sanitize_text(&c)); + let result: UploadAttachmentResult = sanitized.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "Attachment '{}' queued for work item #{}. The file will be uploaded during safe output processing.", + result.file_path, result.work_item_id + ))])) + } + + #[tool( + name = "submit-pr-review", + description = "Submit a pull request review with a decision (approve, request-changes, \ +or comment-only) and an optional body explaining the rationale. The review will be \ +submitted during safe output processing. Requires 'allowed-events' to be configured." + )] + async fn submit_pr_review( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: submit-pr-review - PR #{} event '{}'", + params.0.pull_request_id, params.0.event + ); + let mut sanitized = params.0; + sanitized.body = sanitized.body.map(|b| sanitize_text(&b)); + let result: SubmitPrReviewResult = sanitized.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "PR review '{}' queued for PR #{}. The review will be submitted during safe output processing.", + result.event, result.pull_request_id + ))])) + } + + #[tool( + name = "reply-to-pr-review-comment", + description = "Reply to an existing review comment thread on an Azure DevOps pull request. \ +Provide the PR ID, thread ID, and reply content. The reply will be posted during safe output processing." + )] + async fn reply_to_pr_review_comment( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: reply-to-pr-review-comment - PR #{} thread #{}", + params.0.pull_request_id, params.0.thread_id + ); + let mut sanitized = params.0; + sanitized.content = sanitize_text(&sanitized.content); + let result: ReplyToPrCommentResult = sanitized.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "Reply queued for thread #{} on PR #{}. The reply will be posted during safe output processing.", + result.thread_id, result.pull_request_id + ))])) + } + + #[tool( + name = "resolve-pr-review-thread", + description = "Resolve or change the status of a review thread on an Azure DevOps pull request. \ +Valid statuses: fixed, wont-fix, closed, by-design, active. \ +The status change will be applied during safe output processing." + )] + async fn resolve_pr_review_thread( + &self, + params: Parameters, + ) -> Result { + info!( + "Tool called: resolve-pr-review-thread - PR #{} thread #{} → '{}'", + params.0.pull_request_id, params.0.thread_id, params.0.status + ); + let result: ResolvePrThreadResult = params.0.try_into()?; + self.write_safe_output_file(&result).await + .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; + Ok(CallToolResult::success(vec![Content::text(format!( + "Thread #{} status change to '{}' queued for PR #{}. The change will be applied during safe output processing.", + result.thread_id, result.status, result.pull_request_id + ))])) + } + + #[tool( + name = "report-incomplete", + description = "Signal that the task could not be completed due to infrastructure failure, \ +tool errors, or other environmental issues beyond the agent's control. Use this when the \ +agent attempted work but couldn't finish (e.g., API timeouts, build failures, resource limits)." + )] + async fn report_incomplete( + &self, + params: Parameters, + ) -> Result { + warn!("Tool called: report-incomplete - '{}'", params.0.reason); + let mut sanitized = params.0; + sanitized.reason = sanitize_text(&sanitized.reason); + sanitized.context = sanitized.context.map(|c| sanitize_text(&c)); + let result: ReportIncompleteResult = sanitized.try_into()?; + if let Err(e) = self.write_safe_output_file(&result).await { + warn!("Failed to write report-incomplete safe output: {}", e); + } + Ok(CallToolResult::success(vec![])) + } } // Implement the server handler diff --git a/src/ndjson.rs b/src/ndjson.rs index e75d13d..ceb6311 100644 --- a/src/ndjson.rs +++ b/src/ndjson.rs @@ -51,6 +51,9 @@ pub async fn append_to_ndjson_file(path: &Path, value: &T) -> Res file.write_all(line.as_bytes()) .await .with_context(|| format!("Failed to write to NDJSON file: {}", path.display()))?; + file.flush() + .await + .with_context(|| format!("Failed to flush NDJSON file: {}", path.display()))?; debug!("Appended {} bytes to NDJSON", line.len()); Ok(()) } diff --git a/src/sanitize.rs b/src/sanitize.rs index 598b9bf..136ebd8 100644 --- a/src/sanitize.rs +++ b/src/sanitize.rs @@ -34,6 +34,7 @@ const MAX_LINE_COUNT: usize = 65_536; /// 7. Apply content size limits (IS-08) pub fn sanitize(input: &str) -> String { let mut s = remove_control_characters(input); + s = neutralize_pipeline_commands(&s); s = neutralize_mentions(&s); s = neutralize_bot_triggers(&s); s = remove_xml_comments(&s); @@ -85,6 +86,37 @@ fn remove_control_characters(input: &str) -> String { result } +// ── Azure DevOps pipeline command neutralization ─────────────────────────── + +/// Neutralize `##vso[` logging command sequences that Azure DevOps interprets +/// when they appear in pipeline stdout/stderr. Wraps them in backticks so they +/// render as code instead of being executed. +/// +/// Also handles `##[` (the shorthand form used for `##[section]`, `##[error]`, +/// etc.) which ADO pipelines also interpret. +fn neutralize_pipeline_commands(input: &str) -> String { + let mut result = String::with_capacity(input.len() + 32); + let mut rest = input; + + while let Some(pos) = rest.find("##") { + result.push_str(&rest[..pos]); + let after = &rest[pos + 2..]; + if after.starts_with("vso[") { + result.push_str("`##vso[`"); + rest = &after[4..]; + } else if after.starts_with('[') { + result.push_str("`##[`"); + rest = &after[1..]; + } else { + // Harmless "##" (e.g. markdown heading) + result.push_str("##"); + rest = after; + } + } + result.push_str(rest); + result +} + // ── IS-04: @mention neutralization ───────────────────────────────────────── /// Wrap @mentions in backticks to prevent unintended notifications. @@ -482,4 +514,32 @@ mod tests { let input = "This is a normal description of a work item."; assert_eq!(sanitize(input), input); } + + // ── Pipeline command neutralization tests ────────────────────────────── + + #[test] + fn test_neutralize_vso_command() { + let input = "##vso[task.setvariable variable=secret]hack"; + let result = sanitize(input); + // The raw ##vso[ should be wrapped in backticks so ADO won't interpret it + assert!(result.contains("`##vso[`")); + // Verify it's not present in its original unescaped form (i.e. without backtick prefix) + assert!(!result.contains("##vso[task.")); + } + + #[test] + fn test_neutralize_vso_shorthand() { + let input = "##[error]Something bad happened"; + let result = sanitize(input); + assert!(result.contains("`##[`")); + } + + #[test] + fn test_neutralize_vso_preserves_single_hash() { + let input = "# Heading\n## Sub-heading\nIssue #123"; + let result = sanitize(input); + assert!(result.contains("# Heading")); + assert!(result.contains("## Sub-heading")); + assert!(result.contains("#123")); + } } diff --git a/src/tools/add_build_tag.rs b/src/tools/add_build_tag.rs new file mode 100644 index 0000000..536ce23 --- /dev/null +++ b/src/tools/add_build_tag.rs @@ -0,0 +1,316 @@ +//! Add build tag safe output tool + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::PATH_SEGMENT; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +// ── Stage 1: Params (agent-provided) ────────────────────────────────────── + +/// Parameters for adding a tag to an Azure DevOps build +#[derive(Deserialize, JsonSchema)] +pub struct AddBuildTagParams { + /// Build ID to tag (must be positive) + pub build_id: i32, + /// Tag string (1-100 chars, alphanumeric + dashes only) + pub tag: String, +} + +impl Validate for AddBuildTagParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!(self.build_id > 0, "build_id must be positive"); + ensure!(!self.tag.is_empty(), "tag must not be empty"); + ensure!( + self.tag.len() <= 100, + "tag must be at most 100 characters" + ); + ensure!( + self.tag + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-'), + "tag must contain only alphanumeric characters and dashes" + ); + Ok(()) + } +} + +// ── Stage 1: Result (generated by macro) ────────────────────────────────── + +tool_result! { + name = "add-build-tag", + params = AddBuildTagParams, + /// Result of adding a tag to a build + pub struct AddBuildTagResult { + build_id: i32, + tag: String, + } +} + +// ── Stage 2: Sanitization ───────────────────────────────────────────────── + +impl Sanitize for AddBuildTagResult { + fn sanitize_fields(&mut self) { + self.tag = sanitize_text(&self.tag); + } +} + +// ── Stage 2: Configuration (from front matter) ──────────────────────────── + +/// Configuration for the add-build-tag tool +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// add-build-tag: +/// allowed-tags: +/// - verified +/// - release-candidate +/// tag-prefix: "agent-" +/// allow-any-build: false +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddBuildTagConfig { + /// Restrict which tags can be applied. Empty means any tag is allowed. + /// Supports simple wildcard patterns: entries ending with `*` match by prefix. + #[serde(default, rename = "allowed-tags")] + pub allowed_tags: Vec, + + /// Prefix prepended to all tags before applying + #[serde(default, rename = "tag-prefix")] + pub tag_prefix: Option, + + /// Whether the agent may tag any build. When false (default), only the + /// current pipeline build (BUILD_BUILDID) may be tagged. + #[serde(default, rename = "allow-any-build")] + pub allow_any_build: bool, +} + +impl Default for AddBuildTagConfig { + fn default() -> Self { + Self { + allowed_tags: Vec::new(), + tag_prefix: None, + allow_any_build: false, + } + } +} + +// ── Stage 2: Execution ──────────────────────────────────────────────────── + +#[async_trait::async_trait] +impl Executor for AddBuildTagResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!("Adding tag '{}' to build #{}", self.tag, self.build_id); + + // 1. Extract required context + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available")?; + debug!("ADO org: {}, project: {}", org_url, project); + + // 2. Get tool-specific configuration + let config: AddBuildTagConfig = ctx.get_tool_config("add-build-tag"); + debug!("Config: {:?}", config); + + // 2b. Scope check: by default only the current build can be tagged + if !config.allow_any_build { + let current_build_id: Option = std::env::var("BUILD_BUILDID") + .ok() + .and_then(|s| s.parse().ok()); + if let Some(current_id) = current_build_id { + if self.build_id != current_id { + return Ok(ExecutionResult::failure(format!( + "Build #{} cannot be tagged: only the current build (#{}) is \ + allowed unless 'allow-any-build: true' is configured", + self.build_id, current_id + ))); + } + } + // If BUILD_BUILDID is not set (e.g. local execution), allow any build + } + + // 3. Apply tag prefix if configured + let final_tag = match &config.tag_prefix { + Some(prefix) => format!("{}{}", prefix, self.tag), + None => self.tag.clone(), + }; + debug!("Final tag (after prefix): {}", final_tag); + + // 4. Validate against allowed tags (if non-empty) + if !config.allowed_tags.is_empty() { + let allowed = config.allowed_tags.iter().any(|pattern| { + if let Some(prefix) = pattern.strip_suffix('*') { + final_tag.starts_with(prefix) + } else { + *pattern == final_tag + } + }); + if !allowed { + return Ok(ExecutionResult::failure(format!( + "Tag '{}' is not in the allowed tags list", + final_tag + ))); + } + } + + // 5. Build the API URL + let url = format!( + "{}/{}/_apis/build/builds/{}/tags/{}?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + self.build_id, + utf8_percent_encode(&final_tag, PATH_SEGMENT), + ); + debug!("API URL: {}", url); + + // 6. PUT request (empty body) + let client = reqwest::Client::new(); + info!("Sending PUT to add tag '{}' to build #{}", final_tag, self.build_id); + let response = client + .put(&url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to send request")?; + + if response.status().is_success() { + info!( + "Tag '{}' added to build #{}", + final_tag, self.build_id + ); + + Ok(ExecutionResult::success_with_data( + format!( + "Added tag '{}' to build #{}", + final_tag, self.build_id + ), + serde_json::json!({ + "build_id": self.build_id, + "tag": final_tag, + "project": project, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + Ok(ExecutionResult::failure(format!( + "Failed to add tag (HTTP {}): {}", + status, error_body + ))) + } + } +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(AddBuildTagResult::NAME, "add-build-tag"); + } + + #[test] + fn test_params_deserializes() { + let json = r#"{"build_id": 42, "tag": "verified"}"#; + let params: AddBuildTagParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.build_id, 42); + assert_eq!(params.tag, "verified"); + } + + #[test] + fn test_params_converts_to_result() { + let params = AddBuildTagParams { + build_id: 42, + tag: "release-candidate".to_string(), + }; + let result: AddBuildTagResult = params.try_into().unwrap(); + assert_eq!(result.name, "add-build-tag"); + assert_eq!(result.build_id, 42); + assert_eq!(result.tag, "release-candidate"); + } + + #[test] + fn test_validation_rejects_zero_build_id() { + let params = AddBuildTagParams { + build_id: 0, + tag: "verified".to_string(), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_empty_tag() { + let params = AddBuildTagParams { + build_id: 42, + tag: "".to_string(), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_invalid_tag_chars() { + let params = AddBuildTagParams { + build_id: 42, + tag: "invalid tag!".to_string(), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = AddBuildTagParams { + build_id: 42, + tag: "verified".to_string(), + }; + let result: AddBuildTagResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains(r#""name":"add-build-tag""#)); + assert!(json.contains(r#""build_id":42"#)); + assert!(json.contains(r#""tag":"verified""#)); + } + + #[test] + fn test_config_defaults() { + let config = AddBuildTagConfig::default(); + assert!(config.allowed_tags.is_empty()); + assert!(config.tag_prefix.is_none()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +allowed-tags: + - verified + - release-candidate +tag-prefix: "agent-" +"#; + let config: AddBuildTagConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.allowed_tags, vec!["verified", "release-candidate"]); + assert_eq!(config.tag_prefix, Some("agent-".to_string())); + } +} diff --git a/src/tools/add_pr_comment.rs b/src/tools/add_pr_comment.rs new file mode 100644 index 0000000..b6672a5 --- /dev/null +++ b/src/tools/add_pr_comment.rs @@ -0,0 +1,610 @@ +//! Add PR comment safe output tool + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::PATH_SEGMENT; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +/// Parameters for adding a comment thread on a pull request +#[derive(Deserialize, JsonSchema)] +pub struct AddPrCommentParams { + /// The pull request ID to comment on + pub pull_request_id: i32, + + /// Comment text in markdown format. Ensure adequate content > 10 characters. + pub content: String, + + /// Repository alias: "self" for pipeline repo, or an alias from the checkout list. + /// Defaults to "self" if omitted. + #[serde(default = "default_repository")] + pub repository: String, + + /// File path for an inline comment. When set, the comment is anchored to this file. + #[serde(default)] + pub file_path: Option, + + /// Starting line number for a multi-line inline comment. Requires `file_path` and `line`. + /// When set, the comment spans from `start_line` to `line`. Must be strictly less than + /// `line` (use `line` alone for single-line comments — do not pass `start_line == line`). + #[serde(default)] + pub start_line: Option, + + /// Line number for an inline comment. Requires `file_path` to be set. + #[serde(default)] + pub line: Option, + + /// Thread status: "active" (default), "fixed", "wont-fix", "closed", or "by-design". + /// CamelCase forms ("Active", "WontFix", etc.) are also accepted for backwards compatibility. + #[serde(default = "default_status")] + pub status: String, +} + +fn default_repository() -> String { + "self".to_string() +} + +fn default_status() -> String { + "active".to_string() +} + +impl Validate for AddPrCommentParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!(self.pull_request_id > 0, "pull_request_id must be positive"); + ensure!( + self.content.len() >= 10, + "content must be at least 10 characters" + ); + ensure!( + status_to_int(&self.status).is_some(), + "status must be one of: {}", + VALID_STATUSES.join(", ") + ); + if self.line.is_some() { + ensure!( + self.file_path.is_some(), + "line requires file_path to be set" + ); + } + if self.start_line.is_some() { + ensure!( + self.line.is_some(), + "start_line requires line to be set" + ); + if let (Some(start), Some(end)) = (self.start_line, self.line) { + ensure!( + start < end, + "start_line ({}) must be less than line ({})", + start, + end + ); + } + } + if let Some(fp) = &self.file_path { + validate_file_path(fp)?; + } + Ok(()) + } +} + +tool_result! { + name = "add-pr-comment", + params = AddPrCommentParams, + /// Result of adding a comment thread on a pull request + pub struct AddPrCommentResult { + pull_request_id: i32, + content: String, + repository: String, + file_path: Option, + start_line: Option, + line: Option, + status: String, + } +} + +impl Sanitize for AddPrCommentResult { + fn sanitize_fields(&mut self) { + self.content = sanitize_text(&self.content); + // Strip control characters from structural fields for defense-in-depth + self.repository = self.repository.chars().filter(|c| !c.is_control()).collect(); + self.status = self.status.chars().filter(|c| !c.is_control()).collect(); + self.file_path = self.file_path.as_ref().map(|fp| { + fp.chars().filter(|c| !c.is_control()).collect() + }); + } +} + +/// Configuration for the add-pr-comment tool (specified in front matter) +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// add-pr-comment: +/// comment-prefix: "[Agent Review] " +/// allowed-repositories: +/// - self +/// - other-repo +/// allowed-statuses: +/// - Active +/// - Closed +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddPrCommentConfig { + /// Prefix prepended to all comments (e.g., "[Agent Review] ") + #[serde(default, rename = "comment-prefix")] + pub comment_prefix: Option, + + /// Restrict which repositories the agent can comment on. + /// If empty, all repositories in the checkout list (plus "self") are allowed. + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + + /// Restrict which thread statuses can be set. + /// If empty, all valid statuses are allowed. + #[serde(default, rename = "allowed-statuses")] + pub allowed_statuses: Vec, +} + +impl Default for AddPrCommentConfig { + fn default() -> Self { + Self { + comment_prefix: None, + allowed_repositories: Vec::new(), + allowed_statuses: Vec::new(), + } + } +} + +/// Map a thread status string to the ADO API integer value. +/// Accepts both kebab-case (preferred) and CamelCase for backwards compatibility. +fn status_to_int(status: &str) -> Option { + match status { + "active" | "Active" => Some(1), + "fixed" | "Fixed" => Some(2), + "wont-fix" | "WontFix" => Some(3), + "closed" | "Closed" => Some(4), + "by-design" | "ByDesign" => Some(5), + _ => None, + } +} + +/// All valid thread status strings (kebab-case canonical form) +const VALID_STATUSES: &[&str] = &["active", "fixed", "wont-fix", "closed", "by-design"]; + +/// Validate a file path for inline comments: no `..` path traversal, not absolute +fn validate_file_path(path: &str) -> anyhow::Result<()> { + ensure!(!path.is_empty(), "file_path must not be empty"); + ensure!( + !path.split(['/', '\\']).any(|component| component == ".."), + "file_path must not contain a '..' path component" + ); + ensure!( + !path.starts_with('/') && !path.starts_with('\\'), + "file_path must not be absolute" + ); + Ok(()) +} + +#[async_trait::async_trait] +impl Executor for AddPrCommentResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!( + "Adding comment to PR #{}: {} chars", + self.pull_request_id, + self.content.len() + ); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + let config: AddPrCommentConfig = ctx.get_tool_config("add-pr-comment"); + debug!("Config: {:?}", config); + + // Validate repository against allowed-repositories config + if !config.allowed_repositories.is_empty() + && !config.allowed_repositories.contains(&self.repository) + { + return Ok(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list", + self.repository + ))); + } + + // Validate status against allowed-statuses config (case-insensitive) + if !config.allowed_statuses.is_empty() + && !config + .allowed_statuses + .iter() + .any(|s| s.eq_ignore_ascii_case(&self.status)) + { + return Ok(ExecutionResult::failure(format!( + "Status '{}' is not in the allowed-statuses list", + self.status + ))); + } + + // Validate status is a known value + let status_int = match status_to_int(&self.status) { + Some(v) => v, + None => { + return Ok(ExecutionResult::failure(format!( + "Invalid status '{}'. Valid statuses: {}", + self.status, + VALID_STATUSES.join(", ") + ))); + } + }; + + // Validate file_path if present + if let Some(ref fp) = self.file_path { + if let Err(e) = validate_file_path(fp) { + return Ok(ExecutionResult::failure(format!( + "Invalid file_path: {}", + e + ))); + } + } + + // Determine the repository name for the API call + let repo_name = if self.repository == "self" || self.repository.is_empty() { + ctx.repository_name + .as_ref() + .context("BUILD_REPOSITORY_NAME not set and repository is 'self'")? + .clone() + } else { + match ctx.allowed_repositories.get(&self.repository) { + Some(name) => name.clone(), + None => { + return Ok(ExecutionResult::failure(format!( + "Repository alias '{}' not found in allowed repositories", + self.repository + ))); + } + } + }; + + // Build comment content with optional prefix + let comment_body = match &config.comment_prefix { + Some(prefix) => format!("{}{}", prefix, self.content), + None => self.content.clone(), + }; + + // Build the API URL + let url = format!( + "{}/{}/_apis/git/repositories/{}/pullRequests/{}/threads?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(&repo_name, PATH_SEGMENT), + self.pull_request_id, + ); + debug!("API URL: {}", url); + + // Build the request body + let comment_obj = serde_json::json!({ + "parentCommentId": 0, + "content": comment_body, + "commentType": 1 + }); + + let mut thread_body = serde_json::json!({ + "comments": [comment_obj], + "status": status_int + }); + + // Add thread context for inline comments + if let Some(ref fp) = self.file_path { + let end_line = self.line.unwrap_or(1); + let start_line = self.start_line.unwrap_or(end_line); + thread_body["threadContext"] = serde_json::json!({ + "filePath": format!("/{}", fp), + "rightFileStart": { "line": start_line, "offset": 1 }, + "rightFileEnd": { "line": end_line, "offset": 1 } + }); + } + + let client = reqwest::Client::new(); + + info!("Sending comment thread to PR #{}", self.pull_request_id); + let response = client + .post(&url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&thread_body) + .send() + .await + .context("Failed to send request to Azure DevOps")?; + + if response.status().is_success() { + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + let thread_id = body.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + + info!( + "Comment thread added to PR #{}: thread #{}", + self.pull_request_id, thread_id + ); + + Ok(ExecutionResult::success_with_data( + format!( + "Added comment thread #{} to PR #{}", + thread_id, self.pull_request_id + ), + serde_json::json!({ + "thread_id": thread_id, + "pull_request_id": self.pull_request_id, + "repository": repo_name, + "project": project, + "status": self.status, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + Ok(ExecutionResult::failure(format!( + "Failed to add comment to PR #{} (HTTP {}): {}", + self.pull_request_id, status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(AddPrCommentResult::NAME, "add-pr-comment"); + } + + #[test] + fn test_params_deserializes() { + let json = r#"{"pull_request_id": 42, "content": "This is a review comment on the PR."}"#; + let params: AddPrCommentParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.pull_request_id, 42); + assert!(params.content.contains("review comment")); + assert_eq!(params.repository, "self"); + assert!(params.file_path.is_none()); + assert!(params.line.is_none()); + assert_eq!(params.status, "active"); + } + + #[test] + fn test_params_converts_to_result() { + let params = AddPrCommentParams { + pull_request_id: 42, + content: "This is a test comment with enough characters.".to_string(), + repository: "self".to_string(), + file_path: None, + start_line: None, + line: None, + status: "active".to_string(), + }; + let result: AddPrCommentResult = params.try_into().unwrap(); + assert_eq!(result.name, "add-pr-comment"); + assert_eq!(result.pull_request_id, 42); + assert!(result.content.contains("test comment")); + } + + #[test] + fn test_validation_rejects_zero_pr_id() { + let params = AddPrCommentParams { + pull_request_id: 0, + content: "This is a valid comment body text.".to_string(), + repository: "self".to_string(), + file_path: None, + start_line: None, + line: None, + status: "active".to_string(), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_short_content() { + let params = AddPrCommentParams { + pull_request_id: 42, + content: "Too short".to_string(), + repository: "self".to_string(), + file_path: None, + start_line: None, + line: None, + status: "active".to_string(), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_line_without_file_path() { + let params = AddPrCommentParams { + pull_request_id: 42, + content: "This is a valid comment body text.".to_string(), + repository: "self".to_string(), + file_path: None, + start_line: None, + line: Some(10), + status: "active".to_string(), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = AddPrCommentParams { + pull_request_id: 42, + content: "A comment body that is definitely longer than ten characters.".to_string(), + repository: "self".to_string(), + file_path: Some("src/main.rs".to_string()), + start_line: None, + line: Some(10), + status: "active".to_string(), + }; + let result: AddPrCommentResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"add-pr-comment""#)); + assert!(json.contains(r#""pull_request_id":42"#)); + } + + #[test] + fn test_config_defaults() { + let config = AddPrCommentConfig::default(); + assert!(config.comment_prefix.is_none()); + assert!(config.allowed_repositories.is_empty()); + assert!(config.allowed_statuses.is_empty()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +comment-prefix: "[Agent Review] " +allowed-repositories: + - self + - other-repo +allowed-statuses: + - Active + - Closed +"#; + let config: AddPrCommentConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.comment_prefix, Some("[Agent Review] ".to_string())); + assert_eq!(config.allowed_repositories, vec!["self", "other-repo"]); + assert_eq!(config.allowed_statuses, vec!["Active", "Closed"]); + } + + #[test] + fn test_status_to_int_mapping() { + // Kebab-case (canonical) + assert_eq!(status_to_int("active"), Some(1)); + assert_eq!(status_to_int("fixed"), Some(2)); + assert_eq!(status_to_int("wont-fix"), Some(3)); + assert_eq!(status_to_int("closed"), Some(4)); + assert_eq!(status_to_int("by-design"), Some(5)); + // CamelCase (backwards compat) + assert_eq!(status_to_int("Active"), Some(1)); + assert_eq!(status_to_int("WontFix"), Some(3)); + assert_eq!(status_to_int("ByDesign"), Some(5)); + // Invalid + assert_eq!(status_to_int("Invalid"), None); + } + + #[test] + fn test_validate_file_path_rejects_traversal() { + assert!(validate_file_path("../etc/passwd").is_err()); + assert!(validate_file_path("src/../secret").is_err()); + } + + #[test] + fn test_validate_file_path_rejects_absolute() { + assert!(validate_file_path("/etc/passwd").is_err()); + assert!(validate_file_path("\\windows\\system32").is_err()); + } + + #[test] + fn test_validate_file_path_accepts_valid() { + assert!(validate_file_path("src/main.rs").is_ok()); + assert!(validate_file_path("path/to/file.txt").is_ok()); + // ".." within a component name is not a traversal — must be accepted + assert!(validate_file_path("releases..notes/v1.md").is_ok()); + assert!(validate_file_path("v2..beta/file.txt").is_ok()); + } + + #[test] + fn test_validation_rejects_invalid_status() { + let params = AddPrCommentParams { + pull_request_id: 42, + content: "This is a valid comment body text.".to_string(), + repository: "self".to_string(), + file_path: None, + start_line: None, + line: None, + status: "unknown".to_string(), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("status must be one of")); + } + + #[test] + fn test_validation_accepts_valid_statuses() { + for s in &["active", "fixed", "wont-fix", "closed", "by-design", "Active", "WontFix"] { + let params = AddPrCommentParams { + pull_request_id: 42, + content: "This is a valid comment body text.".to_string(), + repository: "self".to_string(), + file_path: None, + start_line: None, + line: None, + status: s.to_string(), + }; + let result: Result = params.try_into(); + assert!(result.is_ok(), "Expected status '{}' to be valid", s); + } + } + + #[test] + fn test_allowed_statuses_case_insensitive_match() { + // Config has "Active" but agent sends "active" (canonical lowercase) — should be allowed + let config = AddPrCommentConfig { + comment_prefix: None, + allowed_repositories: Vec::new(), + allowed_statuses: vec!["Active".to_string(), "Closed".to_string()], + }; + // Test the exact comparison logic extracted from execute_impl + let status = "active"; + let matched = config + .allowed_statuses + .iter() + .any(|s| s.eq_ignore_ascii_case(status)); + assert!( + matched, + "lowercase 'active' should match config value 'Active'" + ); + } + + #[test] + fn test_allowed_statuses_case_insensitive_reverse() { + // Config has "active" but agent sends "Active" — should be allowed + let config = AddPrCommentConfig { + comment_prefix: None, + allowed_repositories: Vec::new(), + allowed_statuses: vec!["active".to_string()], + }; + let status = "Active"; + let matched = config + .allowed_statuses + .iter() + .any(|s| s.eq_ignore_ascii_case(status)); + assert!( + matched, + "uppercase 'Active' should match config value 'active'" + ); + } +} diff --git a/src/tools/create_branch.rs b/src/tools/create_branch.rs new file mode 100644 index 0000000..6df7166 --- /dev/null +++ b/src/tools/create_branch.rs @@ -0,0 +1,495 @@ +//! Create branch safe output tool + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{PATH_SEGMENT, validate_git_ref_name}; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +/// Parameters for creating a branch +#[derive(Deserialize, JsonSchema)] +pub struct CreateBranchParams { + /// Branch name to create (e.g., "feature/my-analysis"). 1-200 characters. + pub branch_name: String, + + /// Branch to create from (default: "main") + pub source_branch: Option, + + /// Specific commit SHA to branch from (overrides source_branch). Must be a valid 40-character hex string. + pub source_commit: Option, + + /// Repository alias: "self" for pipeline repo, or an alias from the checkout list (default: "self") + pub repository: Option, +} + +impl Validate for CreateBranchParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!(!self.branch_name.is_empty(), "branch_name must not be empty"); + ensure!( + self.branch_name.len() <= 200, + "branch_name must be at most 200 characters" + ); + ensure!( + !self.branch_name.contains(".."), + "branch_name must not contain '..'" + ); + ensure!( + !self.branch_name.contains('\0'), + "branch_name must not contain null bytes" + ); + ensure!( + !self.branch_name.starts_with('-'), + "branch_name must not start with '-'" + ); + ensure!( + !self.branch_name.contains(' '), + "branch_name must not contain spaces" + ); + validate_git_ref_name(&self.branch_name, "branch_name")?; + + if let Some(ref commit) = self.source_commit { + ensure!( + commit.len() == 40 && commit.chars().all(|c| c.is_ascii_hexdigit()), + "source_commit must be a valid 40-character hex SHA" + ); + } + + if let Some(ref branch) = self.source_branch { + ensure!( + !branch.contains(".."), + "source_branch must not contain '..'" + ); + ensure!( + !branch.contains('\0'), + "source_branch must not contain null bytes" + ); + validate_git_ref_name(branch, "source_branch")?; + } + + Ok(()) + } +} + +tool_result! { + name = "create-branch", + params = CreateBranchParams, + /// Result of creating a branch + pub struct CreateBranchResult { + branch_name: String, + source_branch: Option, + source_commit: Option, + repository: Option, + } +} + +impl Sanitize for CreateBranchResult { + fn sanitize_fields(&mut self) { + self.branch_name = sanitize_text(&self.branch_name); + } +} + +/// Configuration for the create-branch tool (specified in front matter) +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// create-branch: +/// branch-pattern: "^agent/.*" +/// allowed-repositories: +/// - self +/// - other-repo +/// allowed-source-branches: +/// - main +/// - develop +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateBranchConfig { + /// Regex pattern that branch names must match + #[serde(default, rename = "branch-pattern")] + pub branch_pattern: Option, + + /// Repositories the agent is allowed to create branches in + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + + /// Source branches the agent is allowed to branch from + #[serde(default, rename = "allowed-source-branches")] + pub allowed_source_branches: Vec, +} + +impl Default for CreateBranchConfig { + fn default() -> Self { + Self { + branch_pattern: None, + allowed_repositories: Vec::new(), + allowed_source_branches: Vec::new(), + } + } +} + +/// Resolve a branch name to its latest commit SHA via the Azure DevOps refs API +async fn resolve_branch_to_commit( + client: &reqwest::Client, + org_url: &str, + project: &str, + token: &str, + repo_name: &str, + branch: &str, +) -> anyhow::Result { + let url = format!( + "{}/{}/_apis/git/repositories/{}/refs", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(repo_name, PATH_SEGMENT), + ); + debug!("Resolving branch '{}' via: {}", branch, url); + + let response = client + .get(&url) + .query(&[ + ("filter", format!("heads/{}", branch).as_str()), + ("api-version", "7.1"), + ]) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to query refs API")?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + anyhow::bail!( + "Failed to resolve branch '{}' (HTTP {}): {}", + branch, + status, + error_body + ); + } + + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse refs response")?; + + body.get("value") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|r| r.get("objectId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .context(format!( + "Branch '{}' not found in repository '{}'", + branch, repo_name + )) +} + +#[async_trait::async_trait] +impl Executor for CreateBranchResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!("Creating branch: '{}'", self.branch_name); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + let config: CreateBranchConfig = ctx.get_tool_config("create-branch"); + debug!("Branch pattern: {:?}", config.branch_pattern); + debug!("Allowed repositories: {:?}", config.allowed_repositories); + debug!( + "Allowed source branches: {:?}", + config.allowed_source_branches + ); + + // Validate branch name against branch-pattern regex (if configured) + if let Some(ref pattern) = config.branch_pattern { + let re = regex_lite::Regex::new(pattern).context(format!( + "Invalid branch-pattern regex: '{}'", + pattern + ))?; + if !re.is_match(&self.branch_name) { + return Ok(ExecutionResult::failure(format!( + "Branch name '{}' does not match required pattern '{}'", + self.branch_name, pattern + ))); + } + debug!("Branch name matches pattern '{}'", pattern); + } + + // Determine repository alias + let repo_alias = self + .repository + .as_deref() + .unwrap_or("self"); + + // Validate repository against config policy BEFORE resolving the name, + // so operators see a policy error rather than a confusing "not in checkout list" error. + if !config.allowed_repositories.is_empty() + && !config.allowed_repositories.contains(&repo_alias.to_string()) + { + return Ok(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list: [{}]", + repo_alias, + config.allowed_repositories.join(", ") + ))); + } + + // Resolve the alias to the actual ADO repo name + let repo_name = if repo_alias == "self" { + ctx.repository_name + .as_deref() + .context("BUILD_REPOSITORY_NAME not set")? + .to_string() + } else { + ctx.allowed_repositories + .get(repo_alias) + .cloned() + .context(format!( + "Repository alias '{}' is not in the allowed checkout list", + repo_alias + ))? + }; + debug!("Resolved repository: {}", repo_name); + + // Validate source_branch against allowed-source-branches (if configured) + let source_branch = self.source_branch.as_deref().unwrap_or("main"); + if !config.allowed_source_branches.is_empty() + && !config.allowed_source_branches.contains(&source_branch.to_string()) + { + return Ok(ExecutionResult::failure(format!( + "Source branch '{}' is not in the allowed-source-branches list", + source_branch + ))); + } + + let client = reqwest::Client::new(); + + // Resolve the source commit SHA + let source_sha = if let Some(ref commit) = self.source_commit { + debug!("Using explicit source commit: {}", commit); + commit.clone() + } else { + debug!("Resolving source branch '{}' to commit", source_branch); + resolve_branch_to_commit(&client, org_url, project, token, &repo_name, source_branch) + .await? + }; + debug!("Source commit SHA: {}", source_sha); + + // Build the refs update URL + let url = format!( + "{}/{}/_apis/git/repositories/{}/refs?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(&repo_name, PATH_SEGMENT), + ); + debug!("API URL: {}", url); + + // Build the ref update request body + let ref_name = if self.branch_name.starts_with("refs/heads/") { + self.branch_name.clone() + } else { + format!("refs/heads/{}", self.branch_name) + }; + + let ref_updates = serde_json::json!([{ + "name": ref_name, + "oldObjectId": "0000000000000000000000000000000000000000", + "newObjectId": source_sha, + }]); + + info!("Creating branch '{}' from commit {}", ref_name, source_sha); + let response = client + .post(&url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&ref_updates) + .send() + .await + .context("Failed to send request to Azure DevOps")?; + + if response.status().is_success() { + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + // Check for per-ref errors in the response + let success = body + .get("value") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|r| r.get("success")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !success { + let custom_message = body + .get("value") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|r| r.get("customMessage")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + + return Ok(ExecutionResult::failure(format!( + "Failed to create branch '{}': {}", + self.branch_name, custom_message + ))); + } + + info!("Branch '{}' created successfully", self.branch_name); + + Ok(ExecutionResult::success_with_data( + format!( + "Created branch '{}' in repository '{}' from commit {}", + self.branch_name, repo_name, &source_sha[..8] + ), + serde_json::json!({ + "branch": self.branch_name, + "ref": ref_name, + "repository": repo_name, + "source_commit": source_sha, + "project": project, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + Ok(ExecutionResult::failure(format!( + "Failed to create branch '{}' (HTTP {}): {}", + self.branch_name, status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(CreateBranchResult::NAME, "create-branch"); + } + + #[test] + fn test_params_deserializes() { + let json = r#"{"branch_name": "feature/my-analysis", "source_branch": "main", "repository": "self"}"#; + let params: CreateBranchParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.branch_name, "feature/my-analysis"); + assert_eq!(params.source_branch, Some("main".to_string())); + assert_eq!(params.repository, Some("self".to_string())); + } + + #[test] + fn test_params_converts_to_result() { + let params = CreateBranchParams { + branch_name: "feature/new-work".to_string(), + source_branch: Some("develop".to_string()), + source_commit: None, + repository: None, + }; + let result: CreateBranchResult = params.try_into().unwrap(); + assert_eq!(result.name, "create-branch"); + assert_eq!(result.branch_name, "feature/new-work"); + assert_eq!(result.source_branch, Some("develop".to_string())); + } + + #[test] + fn test_validation_rejects_empty_branch() { + let params = CreateBranchParams { + branch_name: "".to_string(), + source_branch: None, + source_commit: None, + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_path_traversal() { + let params = CreateBranchParams { + branch_name: "feature/../main".to_string(), + source_branch: None, + source_commit: None, + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_invalid_commit() { + let params = CreateBranchParams { + branch_name: "feature/valid".to_string(), + source_branch: None, + source_commit: Some("not-a-valid-sha".to_string()), + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = CreateBranchParams { + branch_name: "feature/test-branch".to_string(), + source_branch: Some("main".to_string()), + source_commit: None, + repository: None, + }; + let result: CreateBranchResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"create-branch""#)); + assert!(json.contains(r#""branch_name":"feature/test-branch""#)); + } + + #[test] + fn test_config_defaults() { + let config = CreateBranchConfig::default(); + assert!(config.branch_pattern.is_none()); + assert!(config.allowed_repositories.is_empty()); + assert!(config.allowed_source_branches.is_empty()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +branch-pattern: "^agent/.*" +allowed-repositories: + - self + - other-repo +allowed-source-branches: + - main + - develop +"#; + let config: CreateBranchConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.branch_pattern, Some("^agent/.*".to_string())); + assert_eq!(config.allowed_repositories, vec!["self", "other-repo"]); + assert_eq!(config.allowed_source_branches, vec!["main", "develop"]); + } +} diff --git a/src/tools/create_git_tag.rs b/src/tools/create_git_tag.rs new file mode 100644 index 0000000..f7986f9 --- /dev/null +++ b/src/tools/create_git_tag.rs @@ -0,0 +1,514 @@ +//! Create git tag safe output tool + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{PATH_SEGMENT, validate_git_ref_name}; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +/// Parameters for creating a git tag (agent-provided) +#[derive(Deserialize, JsonSchema)] +pub struct CreateGitTagParams { + /// Tag name (e.g., "v1.2.3"). Must be 3-100 characters, alphanumeric + /// plus dots, dashes, underscores, and slashes only. + pub tag_name: String, + + /// Commit SHA to tag. If omitted, the executor resolves HEAD of the + /// default branch. Must be a valid 40-character hex string if present. + pub commit: Option, + + /// Tag annotation message. Must be at least 5 characters if present. + pub message: Option, + + /// Repository alias: "self" for the pipeline repo, or an alias from + /// the `checkout:` list. Defaults to "self". + pub repository: Option, +} + +/// Regex pattern for valid tag names: alphanumeric, dots, dashes, underscores, slashes. +static TAG_NAME_PATTERN: std::sync::LazyLock = + std::sync::LazyLock::new(|| regex_lite::Regex::new(r"^[a-zA-Z0-9._/\-]+$").unwrap()); + +impl Validate for CreateGitTagParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!( + !self.tag_name.starts_with('-'), + "tag_name must not start with '-'" + ); + ensure!( + self.tag_name.len() >= 3, + "tag_name must be at least 3 characters" + ); + ensure!( + self.tag_name.len() <= 100, + "tag_name must be at most 100 characters" + ); + ensure!( + TAG_NAME_PATTERN.is_match(&self.tag_name), + "tag_name contains invalid characters (only alphanumeric, dots, dashes, underscores, and slashes are allowed): {}", + self.tag_name + ); + validate_git_ref_name(&self.tag_name, "tag_name")?; + + if let Some(commit) = &self.commit { + ensure!( + commit.len() == 40, + "commit must be exactly 40 hex characters, got {} characters", + commit.len() + ); + ensure!( + commit.chars().all(|c| c.is_ascii_hexdigit()), + "commit must be a valid hex string: {}", + commit + ); + } + + if let Some(message) = &self.message { + ensure!( + message.len() >= 5, + "message must be at least 5 characters" + ); + } + + Ok(()) + } +} + +tool_result! { + name = "create-git-tag", + params = CreateGitTagParams, + /// Result of creating a git tag + pub struct CreateGitTagResult { + tag_name: String, + commit: Option, + message: Option, + repository: Option, + } +} + +impl Sanitize for CreateGitTagResult { + fn sanitize_fields(&mut self) { + // tag_name is a structural identifier — only strip control characters + self.tag_name = self + .tag_name + .chars() + .filter(|c| !c.is_control()) + .collect(); + self.message = self.message.as_ref().map(|m| sanitize_text(m)); + // commit and repository are structural identifiers; strip control chars only + self.commit = self.commit.as_ref().map(|c| { + c.chars().filter(|ch| !ch.is_control()).collect() + }); + self.repository = self.repository.as_ref().map(|r| { + r.chars().filter(|ch| !ch.is_control()).collect() + }); + } +} + +/// Configuration for the create-git-tag tool (specified in front matter) +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// create-git-tag: +/// tag-pattern: "^v\\d+\\.\\d+\\.\\d+$" +/// allowed-repositories: +/// - self +/// - my-lib +/// message-prefix: "[release] " +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateGitTagConfig { + /// Regex pattern that tag names must match (if configured) + #[serde(default, rename = "tag-pattern")] + pub tag_pattern: Option, + + /// Repositories the agent is allowed to tag (if empty, all allowed) + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + + /// Prefix prepended to the tag message + #[serde(default, rename = "message-prefix")] + pub message_prefix: Option, +} + +impl Default for CreateGitTagConfig { + fn default() -> Self { + Self { + tag_pattern: None, + allowed_repositories: Vec::new(), + message_prefix: None, + } + } +} + +/// Resolve HEAD commit for a repository by querying the repository's default branch. +async fn resolve_head_commit( + client: &reqwest::Client, + org_url: &str, + project: &str, + token: &str, + repo_name: &str, +) -> anyhow::Result { + // First, discover the default branch from the repository metadata + let repo_url = format!( + "{}/{}/_apis/git/repositories/{}?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(repo_name, PATH_SEGMENT), + ); + debug!("Fetching repository metadata: {}", repo_url); + + let repo_response = client + .get(&repo_url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to query repository metadata")?; + + ensure!( + repo_response.status().is_success(), + "Failed to fetch repository metadata (HTTP {})", + repo_response.status() + ); + + let repo_body: serde_json::Value = repo_response + .json() + .await + .context("Failed to parse repository metadata")?; + + let default_branch = repo_body + .get("defaultBranch") + .and_then(|v| v.as_str()) + .unwrap_or("refs/heads/main"); + + // Strip "refs/heads/" prefix for the filter parameter + let branch_filter = default_branch + .strip_prefix("refs/") + .unwrap_or(default_branch); + debug!("Default branch: {} (filter: {})", default_branch, branch_filter); + + // Now resolve the HEAD commit of the default branch + let url = format!( + "{}/{}/_apis/git/repositories/{}/refs?filter={}&api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(repo_name, PATH_SEGMENT), + branch_filter, + ); + + debug!("Resolving HEAD commit via: {}", url); + + let response = client + .get(&url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to query refs for HEAD resolution")?; + + ensure!( + response.status().is_success(), + "Failed to resolve HEAD (HTTP {})", + response.status() + ); + + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse refs response")?; + + body.get("value") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|r| r.get("objectId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .context(format!( + "No refs found for {} — cannot resolve HEAD commit", + default_branch + )) +} + +#[async_trait::async_trait] +impl Executor for CreateGitTagResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!("Creating git tag: '{}'", self.tag_name); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + let config: CreateGitTagConfig = ctx.get_tool_config("create-git-tag"); + debug!("Tag pattern: {:?}", config.tag_pattern); + debug!("Allowed repositories: {:?}", config.allowed_repositories); + + // Validate tag against configured pattern + if let Some(pattern) = &config.tag_pattern { + let re = regex_lite::Regex::new(pattern).context(format!( + "Invalid tag-pattern regex in config: {}", + pattern + ))?; + if !re.is_match(&self.tag_name) { + return Ok(ExecutionResult::failure(format!( + "Tag name '{}' does not match required pattern '{}'", + self.tag_name, pattern + ))); + } + } + + // Resolve repository + let repo_alias = self.repository.as_deref().unwrap_or("self"); + + // Validate repository against config policy BEFORE resolving the name, + // so operators see a policy error rather than a confusing resolution error. + if !config.allowed_repositories.is_empty() + && !config.allowed_repositories.contains(&repo_alias.to_string()) + { + return Ok(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list: [{}]", + repo_alias, + config.allowed_repositories.join(", ") + ))); + } + + let repo_name = if repo_alias == "self" { + ctx.repository_name + .as_deref() + .context("BUILD_REPOSITORY_NAME not set and repository is 'self'")? + .to_string() + } else { + ctx.allowed_repositories + .get(repo_alias) + .cloned() + .context(format!( + "Repository alias '{}' not found in allowed repositories", + repo_alias + ))? + }; + + let client = reqwest::Client::new(); + + // Resolve commit SHA — use provided value or look up HEAD + let commit_sha = match &self.commit { + Some(sha) => sha.clone(), + None => { + info!("No commit specified, resolving HEAD of default branch"); + resolve_head_commit(&client, org_url, project, token, &repo_name).await? + } + }; + debug!("Tagging commit: {}", commit_sha); + + // Build tag message with optional prefix + let tag_message = match (&config.message_prefix, &self.message) { + (Some(prefix), Some(msg)) => format!("{}{}", prefix, msg), + (Some(prefix), None) => format!("{}Tag {}", prefix, self.tag_name), + (None, Some(msg)) => msg.clone(), + (None, None) => format!("Tag {}", self.tag_name), + }; + + // POST annotated tag + let url = format!( + "{}/{}/_apis/git/repositories/{}/annotatedtags?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(&repo_name, PATH_SEGMENT), + ); + debug!("API URL: {}", url); + + let body = serde_json::json!({ + "name": self.tag_name, + "taggedObject": { + "objectId": commit_sha + }, + "message": tag_message + }); + + info!("Sending annotated tag creation request to ADO"); + let response = client + .post(&url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&body) + .send() + .await + .context("Failed to send request to Azure DevOps")?; + + if response.status().is_success() { + let resp_body: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + let tag_url = resp_body + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + info!("Tag created: {} -> {}", self.tag_name, commit_sha); + + Ok(ExecutionResult::success_with_data( + format!( + "Created tag '{}' on commit {} in repository '{}'", + self.tag_name, commit_sha, repo_name + ), + serde_json::json!({ + "tag": self.tag_name, + "commit": commit_sha, + "repository": repo_name, + "url": tag_url, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + Ok(ExecutionResult::failure(format!( + "Failed to create tag (HTTP {}): {}", + status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(CreateGitTagResult::NAME, "create-git-tag"); + } + + #[test] + fn test_params_deserializes() { + let json = r#"{"tag_name": "v1.2.3", "commit": "abcdef1234567890abcdef1234567890abcdef12", "message": "Release v1.2.3"}"#; + let params: CreateGitTagParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.tag_name, "v1.2.3"); + assert_eq!( + params.commit.as_deref(), + Some("abcdef1234567890abcdef1234567890abcdef12") + ); + assert_eq!(params.message.as_deref(), Some("Release v1.2.3")); + } + + #[test] + fn test_params_converts_to_result() { + let params = CreateGitTagParams { + tag_name: "v1.0.0".to_string(), + commit: Some("abcdef1234567890abcdef1234567890abcdef12".to_string()), + message: Some("Initial release".to_string()), + repository: None, + }; + let result: CreateGitTagResult = params.try_into().unwrap(); + assert_eq!(result.name, "create-git-tag"); + assert_eq!(result.tag_name, "v1.0.0"); + } + + #[test] + fn test_validation_rejects_empty_tag() { + let params = CreateGitTagParams { + tag_name: "ab".to_string(), + commit: None, + message: None, + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_invalid_tag_chars() { + let params = CreateGitTagParams { + tag_name: "v1.0 invalid!".to_string(), + commit: None, + message: None, + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_invalid_commit_sha() { + let params = CreateGitTagParams { + tag_name: "v1.0.0".to_string(), + commit: Some("not-a-valid-sha".to_string()), + message: None, + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_short_message() { + let params = CreateGitTagParams { + tag_name: "v1.0.0".to_string(), + commit: None, + message: Some("Hi".to_string()), + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = CreateGitTagParams { + tag_name: "v2.0.0".to_string(), + commit: Some("abcdef1234567890abcdef1234567890abcdef12".to_string()), + message: Some("Major release".to_string()), + repository: None, + }; + let result: CreateGitTagResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"create-git-tag""#)); + assert!(json.contains(r#""tag_name":"v2.0.0""#)); + } + + #[test] + fn test_config_defaults() { + let config = CreateGitTagConfig::default(); + assert!(config.tag_pattern.is_none()); + assert!(config.allowed_repositories.is_empty()); + assert!(config.message_prefix.is_none()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +tag-pattern: "^v\\d+\\.\\d+\\.\\d+$" +allowed-repositories: + - self + - my-lib +message-prefix: "[release] " +"#; + let config: CreateGitTagConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + config.tag_pattern.as_deref(), + Some("^v\\d+\\.\\d+\\.\\d+$") + ); + assert_eq!(config.allowed_repositories, vec!["self", "my-lib"]); + assert_eq!(config.message_prefix.as_deref(), Some("[release] ")); + } +} diff --git a/src/tools/create_wiki_page.rs b/src/tools/create_wiki_page.rs index 5c8673f..8c33664 100644 --- a/src/tools/create_wiki_page.rs +++ b/src/tools/create_wiki_page.rs @@ -7,6 +7,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::PATH_SEGMENT; +use super::resolve_wiki_branch; use crate::sanitize::{Sanitize, sanitize as sanitize_text}; use crate::tool_result; use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; @@ -108,6 +109,12 @@ pub struct CreateWikiPageConfig { #[serde(default, rename = "wiki-project")] pub wiki_project: Option, + /// Git branch for the wiki. Required for **code wikis** (type 1) where the + /// ADO API demands an explicit `versionDescriptor`. For project wikis this + /// can be omitted (defaults to `wikiMaster` server-side). + #[serde(default)] + pub branch: Option, + /// Security restriction: the agent may only create wiki pages whose paths /// start with this prefix (e.g. `"/agent-output"`). Paths that do not match /// are rejected at execution time. When omitted, no restriction is applied. @@ -231,13 +238,34 @@ impl Executor for CreateWikiPageResult { let client = reqwest::Client::new(); + // Resolve the effective branch: explicit config → auto-detect from wiki + // metadata (code wikis need an explicit versionDescriptor). + let resolved_branch = match resolve_wiki_branch( + &client, + org_url, + project, + wiki_name, + token, + config.branch.as_deref(), + ) + .await + { + Ok(b) => b, + Err(msg) => return Ok(ExecutionResult::failure(msg)), + }; + // ── GET: check whether the page already exists ──────────────────────── + let mut get_query: Vec<(&str, &str)> = vec![ + ("path", effective_path.as_str()), + ("api-version", "7.0"), + ]; + if let Some(branch) = &resolved_branch { + get_query.push(("versionDescriptor.version", branch.as_str())); + get_query.push(("versionDescriptor.versionType", "branch")); + } let get_resp = client .get(&base_url) - .query(&[ - ("path", effective_path.as_str()), - ("api-version", "7.0"), - ]) + .query(&get_query) .basic_auth("", Some(token)) .send() .await @@ -276,13 +304,18 @@ impl Executor for CreateWikiPageResult { // with 412 if this resource already exists", closing the TOCTOU race // between our GET (404) and the PUT where a concurrent request could // create the page first. + let mut put_query: Vec<(&str, &str)> = vec![ + ("path", effective_path.as_str()), + ("comment", comment), + ("api-version", "7.0"), + ]; + if let Some(branch) = &resolved_branch { + put_query.push(("versionDescriptor.version", branch.as_str())); + put_query.push(("versionDescriptor.versionType", "branch")); + } let put_resp = client .put(&base_url) - .query(&[ - ("path", effective_path.as_str()), - ("comment", comment), - ("api-version", "7.0"), - ]) + .query(&put_query) .header("Content-Type", "application/json") .header("If-Match", "") .basic_auth("", Some(token)) @@ -495,6 +528,7 @@ mod tests { let config = CreateWikiPageConfig::default(); assert!(config.wiki_name.is_none()); assert!(config.wiki_project.is_none()); + assert!(config.branch.is_none()); assert!(config.path_prefix.is_none()); assert!(config.title_prefix.is_none()); assert!(config.comment.is_none()); @@ -512,11 +546,23 @@ comment: "Created by agent" let config: CreateWikiPageConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(config.wiki_name.as_deref(), Some("MyProject.wiki")); assert_eq!(config.wiki_project.as_deref(), Some("OtherProject")); + assert!(config.branch.is_none()); assert_eq!(config.path_prefix.as_deref(), Some("/agent-output")); assert_eq!(config.title_prefix.as_deref(), Some("[Agent] ")); assert_eq!(config.comment.as_deref(), Some("Created by agent")); } + #[test] + fn test_config_deserializes_with_branch() { + let yaml = r#" +wiki-name: "Azure Sphere" +branch: "main" +"#; + let config: CreateWikiPageConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.wiki_name.as_deref(), Some("Azure Sphere")); + assert_eq!(config.branch.as_deref(), Some("main")); + } + #[test] fn test_config_partial_deserialize_uses_defaults() { let yaml = r#" diff --git a/src/tools/link_work_items.rs b/src/tools/link_work_items.rs new file mode 100644 index 0000000..71609cd --- /dev/null +++ b/src/tools/link_work_items.rs @@ -0,0 +1,460 @@ +//! Link work items safe output tool + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::PATH_SEGMENT; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::tools::comment_on_work_item::CommentTarget; +use anyhow::{Context, ensure}; + +/// Resolve a human-friendly link type name to the ADO relation type string. +fn resolve_link_type(link_type: &str) -> Option<&'static str> { + match link_type { + "parent" => Some("System.LinkTypes.Hierarchy-Reverse"), + "child" => Some("System.LinkTypes.Hierarchy-Forward"), + "related" => Some("System.LinkTypes.Related"), + "predecessor" => Some("System.LinkTypes.Dependency-Reverse"), + "successor" => Some("System.LinkTypes.Dependency-Forward"), + "duplicate" => Some("System.LinkTypes.Duplicate-Forward"), + "duplicate-of" => Some("System.LinkTypes.Duplicate-Reverse"), + _ => None, + } +} + +/// All valid link type names accepted by this tool. +const VALID_LINK_TYPES: &[&str] = &[ + "parent", + "child", + "related", + "predecessor", + "successor", + "duplicate", + "duplicate-of", +]; + +/// Parameters for linking two work items +#[derive(Deserialize, JsonSchema)] +pub struct LinkWorkItemsParams { + /// The source work item ID (the item the link is added to) + pub source_id: i64, + + /// The target work item ID (the item being linked to) + pub target_id: i64, + + /// Link type: parent, child, related, predecessor, successor, duplicate, duplicate-of + pub link_type: String, + + /// Optional comment describing the relationship + pub comment: Option, +} + +impl Validate for LinkWorkItemsParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!(self.source_id > 0, "source_id must be positive"); + ensure!(self.target_id > 0, "target_id must be positive"); + ensure!( + self.source_id != self.target_id, + "source_id and target_id must be different" + ); + ensure!( + resolve_link_type(&self.link_type).is_some(), + "invalid link_type '{}'; must be one of: {}", + self.link_type, + VALID_LINK_TYPES.join(", ") + ); + if let Some(ref comment) = self.comment { + ensure!( + comment.len() >= 5, + "comment must be at least 5 characters" + ); + } + Ok(()) + } +} + +tool_result! { + name = "link-work-items", + params = LinkWorkItemsParams, + default_max = 5, + /// Result of linking two work items + pub struct LinkWorkItemsResult { + source_id: i64, + target_id: i64, + link_type: String, + comment: Option, + } +} + +impl Sanitize for LinkWorkItemsResult { + fn sanitize_fields(&mut self) { + self.link_type = sanitize_text(&self.link_type); + self.comment = self.comment.as_deref().map(sanitize_text); + } +} + +/// Configuration for the link-work-items tool (specified in front matter) +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// link-work-items: +/// target: "*" +/// allowed-link-types: +/// - parent +/// - child +/// - related +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinkWorkItemsConfig { + /// Restrict which link types the agent may use. + /// An empty list (the default) means all link types are allowed. + #[serde(default, rename = "allowed-link-types")] + pub allowed_link_types: Vec, + + /// Target scope — which work items can be linked. + /// `None` means no target was configured; execution must reject this. + /// Accepts the same values as comment-on-work-item: "*", a single ID, + /// a list of IDs, or an area path string. + pub target: Option, +} + +impl Default for LinkWorkItemsConfig { + fn default() -> Self { + Self { + allowed_link_types: Vec::new(), + target: None, + } + } +} + +#[async_trait::async_trait] +impl Executor for LinkWorkItemsResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!( + "Linking work item #{} -> #{} ({})", + self.source_id, self.target_id, self.link_type + ); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + let config: LinkWorkItemsConfig = ctx.get_tool_config("link-work-items"); + debug!("Allowed link types: {:?}", config.allowed_link_types); + + // Validate work item IDs against target scope + match &config.target { + None => { + return Ok(ExecutionResult::failure( + "link-work-items requires a 'target' field in safe-outputs configuration \ + to scope which work items can be linked. Example:\n safe-outputs:\n \ + link-work-items:\n target: \"*\"" + .to_string(), + )); + } + Some(target) => { + // Check source_id + if let Some(false) = target.allows_id(self.source_id) { + return Ok(ExecutionResult::failure(format!( + "Source work item #{} is not allowed by the configured target scope", + self.source_id + ))); + } + // Check target_id + if let Some(false) = target.allows_id(self.target_id) { + return Ok(ExecutionResult::failure(format!( + "Target work item #{} is not allowed by the configured target scope", + self.target_id + ))); + } + // Area path validation is deferred — would need API calls for both IDs. + // For now, ID-based and wildcard scoping is enforced. + } + } + + // Validate link type against configured allow-list + if !config.allowed_link_types.is_empty() + && !config.allowed_link_types.contains(&self.link_type) + { + return Ok(ExecutionResult::failure(format!( + "Link type '{}' is not in the allowed set: {}", + self.link_type, + config.allowed_link_types.join(", ") + ))); + } + + let relation_type = match resolve_link_type(&self.link_type) { + Some(rt) => rt, + None => { + return Ok(ExecutionResult::failure(format!( + "Unknown link type '{}'; must be one of: {}", + self.link_type, + VALID_LINK_TYPES.join(", ") + ))); + } + }; + + // Build the target work item URL for the relation + let target_url = format!( + "{}/{}/_apis/wit/workitems/{}", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + self.target_id, + ); + + // Build the JSON Patch body + let mut relation_value = serde_json::json!({ + "rel": relation_type, + "url": target_url, + }); + + if let Some(ref comment) = self.comment { + relation_value["attributes"] = serde_json::json!({ + "comment": comment, + }); + } + + let patch_doc = vec![serde_json::json!({ + "op": "add", + "path": "/relations/-", + "value": relation_value, + })]; + + // PATCH https://dev.azure.com/{org}/{project}/_apis/wit/workitems/{id}?api-version=7.1 + let url = format!( + "{}/{}/_apis/wit/workitems/{}?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + self.source_id, + ); + debug!("API URL: {}", url); + + let client = reqwest::Client::new(); + + info!( + "Sending link request: #{} -[{}]-> #{}", + self.source_id, self.link_type, self.target_id + ); + let response = client + .patch(&url) + .header("Content-Type", "application/json-patch+json") + .basic_auth("", Some(token)) + .json(&patch_doc) + .send() + .await + .context("Failed to send request to Azure DevOps")?; + + if response.status().is_success() { + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + let work_item_id = body.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + + info!( + "Linked work item #{} -> #{} ({})", + self.source_id, self.target_id, self.link_type + ); + + Ok(ExecutionResult::success_with_data( + format!( + "Linked work item #{} -> #{} ({})", + self.source_id, self.target_id, self.link_type + ), + serde_json::json!({ + "source_id": self.source_id, + "target_id": self.target_id, + "link_type": self.link_type, + "relation_type": relation_type, + "work_item_id": work_item_id, + "project": project, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + Ok(ExecutionResult::failure(format!( + "Failed to link work item #{} -> #{} (HTTP {}): {}", + self.source_id, self.target_id, status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(LinkWorkItemsResult::NAME, "link-work-items"); + } + + #[test] + fn test_params_deserializes() { + let json = + r#"{"source_id": 100, "target_id": 200, "link_type": "parent", "comment": "test linking"}"#; + let params: LinkWorkItemsParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.source_id, 100); + assert_eq!(params.target_id, 200); + assert_eq!(params.link_type, "parent"); + assert_eq!(params.comment.as_deref(), Some("test linking")); + } + + #[test] + fn test_params_converts_to_result() { + let params = LinkWorkItemsParams { + source_id: 100, + target_id: 200, + link_type: "child".to_string(), + comment: Some("Links parent to child".to_string()), + }; + let result: LinkWorkItemsResult = params.try_into().unwrap(); + assert_eq!(result.name, "link-work-items"); + assert_eq!(result.source_id, 100); + assert_eq!(result.target_id, 200); + assert_eq!(result.link_type, "child"); + assert_eq!(result.comment.as_deref(), Some("Links parent to child")); + } + + #[test] + fn test_validation_rejects_zero_source_id() { + let params = LinkWorkItemsParams { + source_id: 0, + target_id: 200, + link_type: "related".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_zero_target_id() { + let params = LinkWorkItemsParams { + source_id: 100, + target_id: 0, + link_type: "related".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_same_ids() { + let params = LinkWorkItemsParams { + source_id: 100, + target_id: 100, + link_type: "related".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_invalid_link_type() { + let params = LinkWorkItemsParams { + source_id: 100, + target_id: 200, + link_type: "unknown".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_link_type() { + assert_eq!( + resolve_link_type("parent"), + Some("System.LinkTypes.Hierarchy-Reverse") + ); + assert_eq!( + resolve_link_type("child"), + Some("System.LinkTypes.Hierarchy-Forward") + ); + assert_eq!( + resolve_link_type("related"), + Some("System.LinkTypes.Related") + ); + assert_eq!( + resolve_link_type("predecessor"), + Some("System.LinkTypes.Dependency-Reverse") + ); + assert_eq!( + resolve_link_type("successor"), + Some("System.LinkTypes.Dependency-Forward") + ); + assert_eq!( + resolve_link_type("duplicate"), + Some("System.LinkTypes.Duplicate-Forward") + ); + assert_eq!( + resolve_link_type("duplicate-of"), + Some("System.LinkTypes.Duplicate-Reverse") + ); + assert_eq!(resolve_link_type("invalid"), None); + assert_eq!(resolve_link_type(""), None); + } + + #[test] + fn test_config_defaults() { + let config = LinkWorkItemsConfig::default(); + assert!(config.allowed_link_types.is_empty()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +allowed-link-types: + - parent + - child + - related +"#; + let config: LinkWorkItemsConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.allowed_link_types.len(), 3); + assert!(config.allowed_link_types.contains(&"parent".to_string())); + assert!(config.allowed_link_types.contains(&"child".to_string())); + assert!(config.allowed_link_types.contains(&"related".to_string())); + } + + #[test] + fn test_result_serializes_correctly() { + let params = LinkWorkItemsParams { + source_id: 100, + target_id: 200, + link_type: "related".to_string(), + comment: None, + }; + let result: LinkWorkItemsResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"link-work-items""#)); + assert!(json.contains(r#""source_id":100"#)); + assert!(json.contains(r#""target_id":200"#)); + assert!(json.contains(r#""link_type":"related""#)); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index dfabac9..3897226 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,6 +1,7 @@ //! Tool parameter and result structs for MCP tools -use percent_encoding::{AsciiSet, CONTROLS}; +use log::{debug, warn}; +use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; /// Characters to percent-encode in a URL path segment. /// Encodes the structural delimiters that would break URL parsing if left raw: @@ -9,27 +10,206 @@ use percent_encoding::{AsciiSet, CONTROLS}; /// types) against accidental corruption of the URL structure. pub(crate) const PATH_SEGMENT: &AsciiSet = &CONTROLS.add(b'#').add(b'?').add(b'/').add(b' '); +/// Resolve the effective branch for a wiki. +/// +/// If `configured_branch` is `Some`, that value is returned directly. +/// Otherwise the wiki metadata API is queried: code wikis (type 1) return +/// the published branch from the `versions` array; project wikis (type 0) +/// return `Ok(None)` because the server handles branching internally. +/// +/// Returns `Err` when a code wiki is detected but the branch cannot be +/// resolved — callers should surface this as a user-facing failure rather +/// than proceeding to a confusing ADO PUT error. +pub(crate) async fn resolve_wiki_branch( + client: &reqwest::Client, + org_url: &str, + project: &str, + wiki_name: &str, + token: &str, + configured_branch: Option<&str>, +) -> Result, String> { + // Explicit configuration always wins. + if let Some(b) = configured_branch { + return Ok(Some(b.to_owned())); + } + + let url = format!( + "{}/{}/_apis/wiki/wikis/{}", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(wiki_name, PATH_SEGMENT), + ); + + let resp = match client + .get(&url) + .query(&[("api-version", "7.0")]) + .basic_auth("", Some(token)) + .send() + .await + { + Ok(r) => r, + Err(e) => { + warn!("Wiki metadata request failed: {e} — skipping branch auto-detection"); + return Ok(None); + } + }; + + if !resp.status().is_success() { + warn!( + "Wiki metadata request returned HTTP {} — skipping branch auto-detection", + resp.status() + ); + return Ok(None); + } + + let body: serde_json::Value = match resp.json().await { + Ok(b) => b, + Err(e) => { + warn!("Failed to parse wiki metadata response: {e}"); + return Ok(None); + } + }; + + // Detect code wikis. ADO returns the type as a string enum ("codeWiki" / + // "projectWiki") rather than a numeric value, so we check both forms. + let is_code_wiki = match body.get("type") { + Some(serde_json::Value::String(s)) => s.eq_ignore_ascii_case("codewiki"), + Some(serde_json::Value::Number(n)) => n.as_u64() == Some(1), + _ => false, + }; + if !is_code_wiki { + let type_val = body.get("type").cloned().unwrap_or(serde_json::Value::Null); + debug!("Wiki is a project wiki (type {type_val}) — no branch needed"); + return Ok(None); + } + + // Code wiki: extract the published branch from versions[0].version + let branch = body + .get("versions") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.get("version")) + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()); + + match &branch { + Some(b) => { + debug!("Detected code wiki — resolved branch: {b}"); + Ok(branch) + } + None => Err(format!( + "Wiki '{wiki_name}' is a code wiki but its published branch could not be \ + determined. Set 'branch' explicitly in the safe-outputs config." + )), + } +} + +/// Resolve a repository alias to its ADO repo name. +/// +/// "self" (or None) → `ctx.repository_name`, otherwise look up in `ctx.allowed_repositories`. +pub(crate) fn resolve_repo_name( + repo_alias: Option<&str>, + ctx: &ExecutionContext, +) -> Result { + let alias = repo_alias.unwrap_or("self"); + if alias == "self" { + ctx.repository_name + .clone() + .ok_or_else(|| ExecutionResult::failure("BUILD_REPOSITORY_NAME not set")) + } else { + ctx.allowed_repositories + .get(alias) + .cloned() + .ok_or_else(|| { + ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed repository list", + alias + )) + }) + } +} + +/// Validate a string against `git check-ref-format` rules. +/// +/// Returns `Ok(())` if the name is valid, or an `Err` describing the violation. +/// This covers the structural rules that Azure DevOps also enforces — catching +/// them early gives clearer error messages than letting the API fail. +pub(crate) fn validate_git_ref_name(name: &str, label: &str) -> anyhow::Result<()> { + use anyhow::ensure; + + ensure!(!name.is_empty(), "{label} must not be empty"); + ensure!(!name.contains(".."), "{label} must not contain '..'"); + ensure!(!name.contains("@{{"), "{label} must not contain '@{{'"); + ensure!(!name.ends_with('.'), "{label} must not end with '.'"); + ensure!(!name.ends_with(".lock"), "{label} must not end with '.lock'"); + ensure!( + !name.contains('\\'), + "{label} must not contain backslash" + ); + ensure!( + !name.contains("//"), + "{label} must not contain consecutive slashes" + ); + for ch in ['~', '^', ':', '?', '*', '['] { + ensure!( + !name.contains(ch), + "{label} must not contain '{ch}'" + ); + } + for component in name.split('/') { + ensure!( + !component.starts_with('.'), + "{label} path component must not start with '.'" + ); + } + Ok(()) +} + +mod add_build_tag; +mod add_pr_comment; mod comment_on_work_item; +mod create_branch; +mod create_git_tag; mod create_pr; mod create_wiki_page; mod create_work_item; -mod update_wiki_page; +mod link_work_items; pub mod memory; mod missing_data; mod missing_tool; mod noop; +mod queue_build; +mod reply_to_pr_comment; +mod report_incomplete; +mod resolve_pr_thread; mod result; +mod submit_pr_review; +mod update_pr; +mod update_wiki_page; mod update_work_item; +mod upload_attachment; +pub use add_build_tag::*; +pub use add_pr_comment::*; pub use comment_on_work_item::*; +pub use create_branch::*; +pub use create_git_tag::*; pub use create_pr::*; pub use create_wiki_page::*; pub use create_work_item::*; -pub use update_wiki_page::*; +pub use link_work_items::*; pub use missing_data::*; pub use missing_tool::*; pub use noop::*; +pub use queue_build::*; +pub use reply_to_pr_comment::*; +pub use report_incomplete::*; +pub use resolve_pr_thread::*; pub use result::{ ExecutionContext, ExecutionResult, Executor, ToolResult, Validate, anyhow_to_mcp_error, }; +pub use submit_pr_review::*; +pub use update_pr::*; +pub use update_wiki_page::*; pub use update_work_item::*; +pub use upload_attachment::*; diff --git a/src/tools/queue_build.rs b/src/tools/queue_build.rs new file mode 100644 index 0000000..853c3f5 --- /dev/null +++ b/src/tools/queue_build.rs @@ -0,0 +1,500 @@ +//! Queue build safe output tool + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::PATH_SEGMENT; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +/// Parameters for queuing a build +#[derive(Deserialize, JsonSchema)] +pub struct QueueBuildParams { + /// Pipeline definition ID to trigger (must be positive) + pub pipeline_id: i32, + + /// Branch to build (optional, defaults to configured default or "main") + pub branch: Option, + + /// Template parameter key-value pairs (optional) + pub parameters: Option>, + + /// Human-readable reason for triggering the build; at least 5 characters if provided + pub reason: Option, +} + +impl Validate for QueueBuildParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!(self.pipeline_id > 0, "pipeline_id must be positive"); + if let Some(reason) = &self.reason { + ensure!( + reason.len() >= 5, + "reason must be at least 5 characters" + ); + } + if let Some(branch) = &self.branch { + ensure!( + !branch.contains(".."), + "branch name must not contain '..'" + ); + ensure!( + !branch.contains('\0'), + "branch name must not contain null bytes" + ); + } + Ok(()) + } +} + +tool_result! { + name = "queue-build", + params = QueueBuildParams, + default_max = 3, + /// Result of queuing a build + pub struct QueueBuildResult { + pipeline_id: i32, + branch: Option, + parameters: Option>, + reason: Option, + } +} + +impl Sanitize for QueueBuildResult { + fn sanitize_fields(&mut self) { + if let Some(reason) = &self.reason { + self.reason = Some(sanitize_text(reason)); + } + if let Some(branch) = &self.branch { + self.branch = Some(sanitize_text(branch)); + } + if let Some(params) = &self.parameters { + self.parameters = Some( + params + .iter() + .map(|(k, v)| (sanitize_text(k), sanitize_text(v))) + .collect(), + ); + } + } +} + +/// Configuration for the queue-build tool (specified in front matter) +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// queue-build: +/// allowed-pipelines: +/// - 123 +/// - 456 +/// allowed-branches: +/// - main +/// - release/* +/// allowed-parameters: +/// - environment +/// - version +/// default-branch: main +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueueBuildConfig { + /// Pipeline definition IDs that are allowed to be triggered (REQUIRED — empty rejects all) + #[serde(default, rename = "allowed-pipelines")] + pub allowed_pipelines: Vec, + + /// Branches that are allowed to be built (if empty, any branch is allowed) + #[serde(default, rename = "allowed-branches")] + pub allowed_branches: Vec, + + /// Parameter keys that are allowed to be passed (if empty, any parameters are allowed) + #[serde(default, rename = "allowed-parameters")] + pub allowed_parameters: Vec, + + /// Default branch to use when the agent does not specify one + #[serde(default, rename = "default-branch")] + pub default_branch: Option, +} + +impl Default for QueueBuildConfig { + fn default() -> Self { + Self { + allowed_pipelines: Vec::new(), + allowed_branches: Vec::new(), + allowed_parameters: Vec::new(), + default_branch: None, + } + } +} + +#[async_trait::async_trait] +impl Executor for QueueBuildResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!("Queuing build for pipeline definition {}", self.pipeline_id); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + // Get tool-specific configuration + let config: QueueBuildConfig = ctx.get_tool_config("queue-build"); + debug!("Allowed pipelines: {:?}", config.allowed_pipelines); + debug!("Allowed branches: {:?}", config.allowed_branches); + debug!("Allowed parameters: {:?}", config.allowed_parameters); + + // Validate pipeline_id against allowed-pipelines (REQUIRED) + if config.allowed_pipelines.is_empty() { + return Ok(ExecutionResult::failure( + "queue-build allowed-pipelines is not configured. \ + This is required to scope which pipelines the agent can trigger." + .to_string(), + )); + } + if !config.allowed_pipelines.contains(&self.pipeline_id) { + return Ok(ExecutionResult::failure(format!( + "Pipeline definition {} is not in the allowed-pipelines list", + self.pipeline_id + ))); + } + + // Resolve the effective branch + let effective_branch = self + .branch + .as_deref() + .or(config.default_branch.as_deref()) + .unwrap_or("main"); + debug!("Effective branch: {}", effective_branch); + + // Validate branch against allowed-branches (if configured) + if !config.allowed_branches.is_empty() { + let branch_allowed = config.allowed_branches.iter().any(|pattern| { + if pattern.ends_with("/*") { + let prefix = &pattern[..pattern.len() - 2]; + effective_branch.starts_with(prefix) + && effective_branch[prefix.len()..].starts_with('/') + } else { + pattern == effective_branch + } + }); + if !branch_allowed { + return Ok(ExecutionResult::failure(format!( + "Branch '{}' is not in the allowed-branches list", + effective_branch + ))); + } + } + + // Validate parameter keys against allowed-parameters (if configured) + if let Some(params) = &self.parameters { + if !config.allowed_parameters.is_empty() { + for key in params.keys() { + if !config.allowed_parameters.contains(key) { + return Ok(ExecutionResult::failure(format!( + "Parameter '{}' is not in the allowed-parameters list", + key + ))); + } + } + } + // Reject parameter values containing ADO variable/expression syntax + for (key, value) in params { + if value.contains("$(") || value.contains("${{") || value.contains("$[") { + return Ok(ExecutionResult::failure(format!( + "Parameter '{}' value contains ADO variable/expression syntax \ + which is not allowed", + key + ))); + } + } + } + + // Build the source branch ref + let source_branch = if effective_branch.starts_with("refs/") { + effective_branch.to_string() + } else { + format!("refs/heads/{}", effective_branch) + }; + debug!("Source branch ref: {}", source_branch); + + // Build the request body + let mut body = serde_json::json!({ + "definition": { "id": self.pipeline_id }, + "sourceBranch": source_branch, + "reason": "userCreated", + }); + + // Add template parameters as a JSON string if provided + if let Some(params) = &self.parameters { + if !params.is_empty() { + let params_json = serde_json::to_string(params) + .context("Failed to serialize template parameters")?; + body["parameters"] = serde_json::Value::String(params_json); + } + } + + // Build the API URL + let url = format!( + "{}/{}/_apis/build/builds?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + ); + debug!("API URL: {}", url); + + // Make the API call + let client = reqwest::Client::new(); + info!( + "Sending queue build request for pipeline {} on branch {}", + self.pipeline_id, source_branch + ); + let response = client + .post(&url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&body) + .send() + .await + .context("Failed to send request to Azure DevOps")?; + + if response.status().is_success() { + let resp_body: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + let build_id = resp_body.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let build_url = resp_body + .get("_links") + .and_then(|l| l.get("web")) + .and_then(|h| h.get("href")) + .and_then(|h| h.as_str()) + .unwrap_or(""); + + info!("Build queued: #{} - {}", build_id, build_url); + + Ok(ExecutionResult::success_with_data( + format!( + "Queued build #{} for pipeline {} on branch {}", + build_id, self.pipeline_id, effective_branch + ), + serde_json::json!({ + "build_id": build_id, + "pipeline_id": self.pipeline_id, + "branch": effective_branch, + "url": build_url, + "project": project, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + Ok(ExecutionResult::failure(format!( + "Failed to queue build for pipeline {} (HTTP {}): {}", + self.pipeline_id, status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(QueueBuildResult::NAME, "queue-build"); + } + + #[test] + fn test_params_deserializes() { + let json = r#"{"pipeline_id": 123, "branch": "main", "reason": "Nightly rebuild"}"#; + let params: QueueBuildParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.pipeline_id, 123); + assert_eq!(params.branch, Some("main".to_string())); + assert_eq!(params.reason, Some("Nightly rebuild".to_string())); + assert!(params.parameters.is_none()); + } + + #[test] + fn test_params_converts_to_result() { + let params = QueueBuildParams { + pipeline_id: 42, + branch: Some("develop".to_string()), + parameters: None, + reason: Some("Trigger nightly build".to_string()), + }; + let result: QueueBuildResult = params.try_into().unwrap(); + assert_eq!(result.name, "queue-build"); + assert_eq!(result.pipeline_id, 42); + assert_eq!(result.branch, Some("develop".to_string())); + assert_eq!(result.reason, Some("Trigger nightly build".to_string())); + } + + #[test] + fn test_validation_rejects_zero_pipeline_id() { + let params = QueueBuildParams { + pipeline_id: 0, + branch: None, + parameters: None, + reason: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_negative_pipeline_id() { + let params = QueueBuildParams { + pipeline_id: -1, + branch: None, + parameters: None, + reason: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_short_reason() { + let params = QueueBuildParams { + pipeline_id: 1, + branch: None, + parameters: None, + reason: Some("Hi".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_branch_with_dotdot() { + let params = QueueBuildParams { + pipeline_id: 1, + branch: Some("../../etc/passwd".to_string()), + parameters: None, + reason: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_branch_with_null_byte() { + let params = QueueBuildParams { + pipeline_id: 1, + branch: Some("main\0evil".to_string()), + parameters: None, + reason: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_accepts_valid_params() { + let params = QueueBuildParams { + pipeline_id: 123, + branch: Some("main".to_string()), + parameters: None, + reason: Some("Scheduled nightly build".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_ok()); + } + + #[test] + fn test_validation_accepts_minimal_params() { + let params = QueueBuildParams { + pipeline_id: 1, + branch: None, + parameters: None, + reason: None, + }; + let result: Result = params.try_into(); + assert!(result.is_ok()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = QueueBuildParams { + pipeline_id: 99, + branch: Some("release/v1".to_string()), + parameters: None, + reason: Some("Release build trigger".to_string()), + }; + let result: QueueBuildResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"queue-build""#)); + assert!(json.contains(r#""pipeline_id":99"#)); + } + + #[test] + fn test_config_defaults() { + let config = QueueBuildConfig::default(); + assert!(config.allowed_pipelines.is_empty()); + assert!(config.allowed_branches.is_empty()); + assert!(config.allowed_parameters.is_empty()); + assert!(config.default_branch.is_none()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +allowed-pipelines: + - 123 + - 456 +allowed-branches: + - main + - release/* +allowed-parameters: + - environment + - version +default-branch: main +"#; + let config: QueueBuildConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.allowed_pipelines, vec![123, 456]); + assert_eq!(config.allowed_branches, vec!["main", "release/*"]); + assert_eq!(config.allowed_parameters, vec!["environment", "version"]); + assert_eq!(config.default_branch, Some("main".to_string())); + } + + #[test] + fn test_config_partial_deserialize_uses_defaults() { + let yaml = r#" +allowed-pipelines: + - 42 +"#; + let config: QueueBuildConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.allowed_pipelines, vec![42]); + assert!(config.allowed_branches.is_empty()); + assert!(config.allowed_parameters.is_empty()); + assert!(config.default_branch.is_none()); + } + + #[test] + fn test_params_deserializes_with_parameters() { + let json = r#"{"pipeline_id": 10, "parameters": {"env": "prod", "version": "2.0"}}"#; + let params: QueueBuildParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.pipeline_id, 10); + let map = params.parameters.unwrap(); + assert_eq!(map.get("env"), Some(&"prod".to_string())); + assert_eq!(map.get("version"), Some(&"2.0".to_string())); + } +} diff --git a/src/tools/reply_to_pr_comment.rs b/src/tools/reply_to_pr_comment.rs new file mode 100644 index 0000000..2839aea --- /dev/null +++ b/src/tools/reply_to_pr_comment.rs @@ -0,0 +1,346 @@ +//! Reply to PR review comment safe output tool + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::PATH_SEGMENT; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +/// Parameters for replying to an existing review comment thread on a pull request +#[derive(Deserialize, JsonSchema)] +pub struct ReplyToPrCommentParams { + /// The pull request ID containing the thread + pub pull_request_id: i32, + + /// The thread ID to reply to + pub thread_id: i32, + + /// Reply text in markdown format. Ensure adequate content > 10 characters. + pub content: String, + + /// Repository alias: "self" for pipeline repo, or an alias from the checkout list. + /// Defaults to "self" if omitted. + #[serde(default = "default_repository")] + pub repository: Option, +} + +fn default_repository() -> Option { + Some("self".to_string()) +} + +impl Validate for ReplyToPrCommentParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!(self.pull_request_id > 0, "pull_request_id must be positive"); + ensure!(self.thread_id > 0, "thread_id must be positive"); + ensure!( + self.content.len() >= 10, + "content must be at least 10 characters" + ); + Ok(()) + } +} + +tool_result! { + name = "reply-to-pr-review-comment", + params = ReplyToPrCommentParams, + /// Result of replying to a review comment thread on a pull request + pub struct ReplyToPrCommentResult { + pull_request_id: i32, + thread_id: i32, + content: String, + repository: Option, + } +} + +impl Sanitize for ReplyToPrCommentResult { + fn sanitize_fields(&mut self) { + self.content = sanitize_text(&self.content); + } +} + +/// Configuration for the reply-to-pr-review-comment tool (specified in front matter) +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// reply-to-pr-review-comment: +/// comment-prefix: "[Agent] " +/// allowed-repositories: +/// - self +/// - other-repo +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplyToPrCommentConfig { + /// Prefix prepended to all replies (e.g., "[Agent] ") + #[serde(default, rename = "comment-prefix")] + pub comment_prefix: Option, + + /// Restrict which repositories the agent can reply on. + /// If empty, all repositories in the checkout list (plus "self") are allowed. + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, +} + +impl Default for ReplyToPrCommentConfig { + fn default() -> Self { + Self { + comment_prefix: None, + allowed_repositories: Vec::new(), + } + } +} + +#[async_trait::async_trait] +impl Executor for ReplyToPrCommentResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!( + "Replying to PR #{} thread #{}: {} chars", + self.pull_request_id, + self.thread_id, + self.content.len() + ); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + let config: ReplyToPrCommentConfig = ctx.get_tool_config("reply-to-pr-review-comment"); + debug!("Config: {:?}", config); + + let repository = self + .repository + .as_deref() + .unwrap_or("self"); + + // Validate repository against allowed-repositories config + if !config.allowed_repositories.is_empty() + && !config.allowed_repositories.contains(&repository.to_string()) + { + return Ok(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list", + repository + ))); + } + + // Determine the repository name for the API call + let repo_name = if repository == "self" || repository.is_empty() { + ctx.repository_name + .as_ref() + .context("BUILD_REPOSITORY_NAME not set and repository is 'self'")? + .clone() + } else { + match ctx.allowed_repositories.get(repository) { + Some(name) => name.clone(), + None => { + return Ok(ExecutionResult::failure(format!( + "Repository alias '{}' not found in allowed repositories", + repository + ))); + } + } + }; + + // Build comment content with optional prefix + let comment_body = match &config.comment_prefix { + Some(prefix) => format!("{}{}", prefix, self.content), + None => self.content.clone(), + }; + + // Build the API URL for adding a comment to an existing thread + let url = format!( + "{}/{}/_apis/git/repositories/{}/pullRequests/{}/threads/{}/comments?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(&repo_name, PATH_SEGMENT), + self.pull_request_id, + self.thread_id, + ); + debug!("API URL: {}", url); + + // parentCommentId=1 targets the root comment in the thread. In ADO, + // the first comment in a thread is always ID 1 (IDs are thread-scoped). + let request_body = serde_json::json!({ + "parentCommentId": 1, + "content": comment_body, + "commentType": 1 + }); + + let client = reqwest::Client::new(); + + info!( + "Sending reply to PR #{} thread #{}", + self.pull_request_id, self.thread_id + ); + let response = client + .post(&url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&request_body) + .send() + .await + .context("Failed to send request to Azure DevOps")?; + + if response.status().is_success() { + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + let comment_id = body.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + + info!( + "Reply added to PR #{} thread #{}: comment #{}", + self.pull_request_id, self.thread_id, comment_id + ); + + Ok(ExecutionResult::success_with_data( + format!( + "Added reply #{} to PR #{} thread #{}", + comment_id, self.pull_request_id, self.thread_id + ), + serde_json::json!({ + "comment_id": comment_id, + "pull_request_id": self.pull_request_id, + "thread_id": self.thread_id, + "repository": repo_name, + "project": project, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + Ok(ExecutionResult::failure(format!( + "Failed to reply to PR #{} thread #{} (HTTP {}): {}", + self.pull_request_id, self.thread_id, status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(ReplyToPrCommentResult::NAME, "reply-to-pr-review-comment"); + } + + #[test] + fn test_params_deserializes() { + let json = + r#"{"pull_request_id": 42, "thread_id": 7, "content": "This is a reply to the review comment."}"#; + let params: ReplyToPrCommentParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.pull_request_id, 42); + assert_eq!(params.thread_id, 7); + assert!(params.content.contains("reply")); + assert_eq!(params.repository, Some("self".to_string())); + } + + #[test] + fn test_params_converts_to_result() { + let params = ReplyToPrCommentParams { + pull_request_id: 42, + thread_id: 7, + content: "This is a test reply with enough characters.".to_string(), + repository: Some("self".to_string()), + }; + let result: ReplyToPrCommentResult = params.try_into().unwrap(); + assert_eq!(result.name, "reply-to-pr-review-comment"); + assert_eq!(result.pull_request_id, 42); + assert_eq!(result.thread_id, 7); + assert!(result.content.contains("test reply")); + } + + #[test] + fn test_validation_rejects_zero_pr_id() { + let params = ReplyToPrCommentParams { + pull_request_id: 0, + thread_id: 7, + content: "This is a valid reply body text.".to_string(), + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_zero_thread_id() { + let params = ReplyToPrCommentParams { + pull_request_id: 42, + thread_id: 0, + content: "This is a valid reply body text.".to_string(), + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_short_content() { + let params = ReplyToPrCommentParams { + pull_request_id: 42, + thread_id: 7, + content: "Too short".to_string(), + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = ReplyToPrCommentParams { + pull_request_id: 42, + thread_id: 7, + content: "A reply body that is definitely longer than ten characters.".to_string(), + repository: Some("self".to_string()), + }; + let result: ReplyToPrCommentResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"reply-to-pr-review-comment""#)); + assert!(json.contains(r#""pull_request_id":42"#)); + assert!(json.contains(r#""thread_id":7"#)); + } + + #[test] + fn test_config_defaults() { + let config = ReplyToPrCommentConfig::default(); + assert!(config.comment_prefix.is_none()); + assert!(config.allowed_repositories.is_empty()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +comment-prefix: "[Agent] " +allowed-repositories: + - self + - other-repo +"#; + let config: ReplyToPrCommentConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.comment_prefix, Some("[Agent] ".to_string())); + assert_eq!(config.allowed_repositories, vec!["self", "other-repo"]); + } +} diff --git a/src/tools/report_incomplete.rs b/src/tools/report_incomplete.rs new file mode 100644 index 0000000..98248c6 --- /dev/null +++ b/src/tools/report_incomplete.rs @@ -0,0 +1,105 @@ +//! Report incomplete task safe output tool + +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::Validate; +use anyhow::ensure; + +/// Parameters for reporting that a task could not be completed +#[derive(Deserialize, JsonSchema)] +pub struct ReportIncompleteParams { + /// Why the task could not be completed + pub reason: String, + + /// Additional context about what was attempted + #[serde(default)] + pub context: Option, +} + +impl Validate for ReportIncompleteParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!( + self.reason.len() >= 10, + "reason must be at least 10 characters" + ); + Ok(()) + } +} + +tool_result! { + name = "report-incomplete", + params = ReportIncompleteParams, + /// Result of reporting an incomplete task + pub struct ReportIncompleteResult { + reason: String, + #[serde(default)] + context: Option, + } +} + +impl Sanitize for ReportIncompleteResult { + fn sanitize_fields(&mut self) { + self.reason = sanitize_text(&self.reason); + if let Some(ref ctx) = self.context { + self.context = Some(sanitize_text(ctx)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(ReportIncompleteResult::NAME, "report-incomplete"); + } + + #[test] + fn test_params_deserializes() { + let json = r#"{"reason": "API timed out after 30s", "context": "tried 3 retries"}"#; + let params: ReportIncompleteParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.reason, "API timed out after 30s"); + assert_eq!(params.context, Some("tried 3 retries".to_string())); + } + + #[test] + fn test_params_converts_to_result() { + let params = ReportIncompleteParams { + reason: "Build failed with exit code 1".to_string(), + context: Some("ran cargo build".to_string()), + }; + let result: ReportIncompleteResult = params.try_into().unwrap(); + assert_eq!(result.name, "report-incomplete"); + assert_eq!(result.reason, "Build failed with exit code 1"); + assert_eq!(result.context, Some("ran cargo build".to_string())); + } + + #[test] + fn test_validation_rejects_short_reason() { + let params = ReportIncompleteParams { + reason: "short".to_string(), + context: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let result: ReportIncompleteResult = ReportIncompleteParams { + reason: "API timed out after 30s".to_string(), + context: None, + } + .try_into() + .unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"report-incomplete""#)); + assert!(json.contains(r#""reason":"API timed out after 30s""#)); + } +} diff --git a/src/tools/resolve_pr_thread.rs b/src/tools/resolve_pr_thread.rs new file mode 100644 index 0000000..7d54cbd --- /dev/null +++ b/src/tools/resolve_pr_thread.rs @@ -0,0 +1,404 @@ +//! Resolve PR review thread safe output tool + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::resolve_repo_name; +use super::PATH_SEGMENT; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +/// All valid thread status strings (lowercase, agent-facing) +const VALID_STATUSES: &[&str] = &["active", "fixed", "wont-fix", "closed", "by-design"]; + +/// Map a thread status string to the ADO API integer value. +/// +/// ADO thread status values: +/// - 1 = Active +/// - 2 = Fixed (resolved) +/// - 3 = WontFix +/// - 4 = Closed +/// - 5 = ByDesign +fn status_to_int(status: &str) -> Option { + match status { + "active" => Some(1), + "fixed" => Some(2), + "wont-fix" => Some(3), + "closed" => Some(4), + "by-design" => Some(5), + _ => None, + } +} + +fn default_repository() -> Option { + Some("self".to_string()) +} + +/// Parameters for resolving or reactivating a PR review thread +#[derive(Deserialize, JsonSchema)] +pub struct ResolvePrThreadParams { + /// The pull request ID containing the thread + pub pull_request_id: i32, + + /// The thread ID to resolve or reactivate + pub thread_id: i32, + + /// Target status: "fixed", "wont-fix", "closed", "by-design", or "active" (to reactivate) + pub status: String, + + /// Repository alias: "self" for pipeline repo, or an alias from the checkout list. + /// Defaults to "self" if omitted. + #[serde(default = "default_repository")] + pub repository: Option, +} + +impl Validate for ResolvePrThreadParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!( + self.pull_request_id > 0, + "pull_request_id must be positive" + ); + ensure!(self.thread_id > 0, "thread_id must be positive"); + ensure!( + VALID_STATUSES.contains(&self.status.as_str()), + "Invalid status '{}'. Valid statuses: {}", + self.status, + VALID_STATUSES.join(", ") + ); + Ok(()) + } +} + +tool_result! { + name = "resolve-pr-review-thread", + params = ResolvePrThreadParams, + /// Result of resolving or reactivating a PR review thread + pub struct ResolvePrThreadResult { + pull_request_id: i32, + thread_id: i32, + status: String, + repository: Option, + } +} + +impl Sanitize for ResolvePrThreadResult { + fn sanitize_fields(&mut self) { + self.status = sanitize_text(&self.status); + if let Some(ref repo) = self.repository { + self.repository = Some(sanitize_text(repo)); + } + } +} + +/// Configuration for the resolve-pr-review-thread tool (specified in front matter) +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// resolve-pr-review-thread: +/// allowed-repositories: +/// - self +/// - other-repo +/// allowed-statuses: +/// - fixed +/// - wont-fix +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolvePrThreadConfig { + /// Restrict which repositories the agent can operate on. + /// If empty, all repositories in the checkout list (plus "self") are allowed. + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + + /// Restrict which thread statuses can be set. + /// REQUIRED — empty list rejects all status transitions. + #[serde(default, rename = "allowed-statuses")] + pub allowed_statuses: Vec, +} + +impl Default for ResolvePrThreadConfig { + fn default() -> Self { + Self { + allowed_repositories: Vec::new(), + allowed_statuses: Vec::new(), + } + } +} + +#[async_trait::async_trait] +impl Executor for ResolvePrThreadResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!( + "Resolving thread #{} on PR #{} with status '{}'", + self.thread_id, self.pull_request_id, self.status + ); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + let config: ResolvePrThreadConfig = ctx.get_tool_config("resolve-pr-review-thread"); + debug!("Config: {:?}", config); + + // Validate status against allowed-statuses — REQUIRED. + // An empty allowed-statuses list means the operator hasn't opted in, so reject. + // This prevents agents from resolving review threads (e.g. marking security + // concerns as "fixed") without explicit operator consent. + if config.allowed_statuses.is_empty() { + return Ok(ExecutionResult::failure( + "resolve-pr-review-thread requires 'allowed-statuses' to be configured in \ + safe-outputs.resolve-pr-review-thread. This prevents agents from \ + manipulating thread statuses without explicit operator consent. Example:\n \ + safe-outputs:\n resolve-pr-review-thread:\n allowed-statuses:\n \ + - fixed\n\nValid statuses: active, fixed, wont-fix, closed, by-design" + .to_string(), + )); + } + if !config.allowed_statuses.contains(&self.status) { + return Ok(ExecutionResult::failure(format!( + "Status '{}' is not in the allowed-statuses list", + self.status + ))); + } + + let effective_repo = self + .repository + .as_deref() + .unwrap_or("self"); + + // Validate repository against allowed-repositories config + if !config.allowed_repositories.is_empty() + && !config.allowed_repositories.contains(&effective_repo.to_string()) + { + return Ok(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list", + effective_repo + ))); + } + + // Map status string to ADO integer + let status_int = match status_to_int(&self.status) { + Some(v) => v, + None => { + return Ok(ExecutionResult::failure(format!( + "Invalid status '{}'. Valid statuses: {}", + self.status, + VALID_STATUSES.join(", ") + ))); + } + }; + + // Resolve repository alias to actual repo name via the shared helper. + // Treat an empty string the same as "self" (pipeline repository). + let alias = self.repository.as_deref().filter(|s| !s.is_empty()); + let repo_name = match resolve_repo_name(alias, ctx) { + Ok(name) => name, + Err(result) => return Ok(result), + }; + + // Build the Azure DevOps REST API URL for updating a thread + // PATCH https://dev.azure.com/{org}/{project}/_apis/git/repositories/{repo}/pullRequests/{prId}/threads/{threadId}?api-version=7.1 + let url = format!( + "{}/{}/_apis/git/repositories/{}/pullRequests/{}/threads/{}?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(&repo_name, PATH_SEGMENT), + self.pull_request_id, + self.thread_id, + ); + debug!("API URL: {}", url); + + let body = serde_json::json!({ + "status": status_int + }); + + let client = reqwest::Client::new(); + + info!( + "Updating thread #{} on PR #{} to status '{}'", + self.thread_id, self.pull_request_id, self.status + ); + let response = client + .patch(&url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&body) + .send() + .await + .context("Failed to send request to Azure DevOps")?; + + if response.status().is_success() { + let resp_body: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + let returned_id = resp_body.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + + info!( + "Thread #{} on PR #{} updated to status '{}'", + self.thread_id, self.pull_request_id, self.status + ); + + Ok(ExecutionResult::success_with_data( + format!( + "Updated thread #{} on PR #{} to status '{}'", + self.thread_id, self.pull_request_id, self.status + ), + serde_json::json!({ + "thread_id": returned_id, + "pull_request_id": self.pull_request_id, + "repository": repo_name, + "project": project, + "status": self.status, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + Ok(ExecutionResult::failure(format!( + "Failed to update thread #{} on PR #{} (HTTP {}): {}", + self.thread_id, self.pull_request_id, status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(ResolvePrThreadResult::NAME, "resolve-pr-review-thread"); + } + + #[test] + fn test_params_deserializes() { + let json = + r#"{"pull_request_id": 42, "thread_id": 7, "status": "fixed"}"#; + let params: ResolvePrThreadParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.pull_request_id, 42); + assert_eq!(params.thread_id, 7); + assert_eq!(params.status, "fixed"); + assert_eq!(params.repository, Some("self".to_string())); + } + + #[test] + fn test_params_converts_to_result() { + let params = ResolvePrThreadParams { + pull_request_id: 42, + thread_id: 7, + status: "fixed".to_string(), + repository: Some("self".to_string()), + }; + let result: ResolvePrThreadResult = params.try_into().unwrap(); + assert_eq!(result.name, "resolve-pr-review-thread"); + assert_eq!(result.pull_request_id, 42); + assert_eq!(result.thread_id, 7); + assert_eq!(result.status, "fixed"); + } + + #[test] + fn test_validation_rejects_zero_pr_id() { + let params = ResolvePrThreadParams { + pull_request_id: 0, + thread_id: 7, + status: "fixed".to_string(), + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_zero_thread_id() { + let params = ResolvePrThreadParams { + pull_request_id: 42, + thread_id: 0, + status: "fixed".to_string(), + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_invalid_status() { + let params = ResolvePrThreadParams { + pull_request_id: 42, + thread_id: 7, + status: "invalid-status".to_string(), + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = ResolvePrThreadParams { + pull_request_id: 42, + thread_id: 7, + status: "fixed".to_string(), + repository: Some("self".to_string()), + }; + let result: ResolvePrThreadResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"resolve-pr-review-thread""#)); + assert!(json.contains(r#""pull_request_id":42"#)); + assert!(json.contains(r#""thread_id":7"#)); + } + + #[test] + fn test_config_defaults() { + let config = ResolvePrThreadConfig::default(); + assert!(config.allowed_repositories.is_empty()); + assert!(config.allowed_statuses.is_empty()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +allowed-repositories: + - self + - other-repo +allowed-statuses: + - fixed + - wont-fix +"#; + let config: ResolvePrThreadConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.allowed_repositories, vec!["self", "other-repo"]); + assert_eq!(config.allowed_statuses, vec!["fixed", "wont-fix"]); + } + + #[test] + fn test_status_mapping() { + assert_eq!(status_to_int("active"), Some(1)); + assert_eq!(status_to_int("fixed"), Some(2)); + assert_eq!(status_to_int("wont-fix"), Some(3)); + assert_eq!(status_to_int("closed"), Some(4)); + assert_eq!(status_to_int("by-design"), Some(5)); + assert_eq!(status_to_int("invalid"), None); + } +} diff --git a/src/tools/submit_pr_review.rs b/src/tools/submit_pr_review.rs new file mode 100644 index 0000000..5cf1d1c --- /dev/null +++ b/src/tools/submit_pr_review.rs @@ -0,0 +1,545 @@ +//! Submit PR review safe output tool + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{PATH_SEGMENT, resolve_repo_name}; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +/// Valid event values for submit-pr-review +const VALID_EVENTS: &[&str] = &[ + "approve", + "approve-with-suggestions", + "request-changes", + "comment", +]; + +/// Map a review event string to its ADO vote numeric value +fn event_to_vote(event: &str) -> Option { + match event { + "approve" => Some(10), + "approve-with-suggestions" => Some(5), + "request-changes" => Some(-5), + "comment" => Some(0), + _ => None, + } +} + +fn default_repository() -> String { + "self".to_string() +} + +/// Parameters for submitting a pull request review +#[derive(Deserialize, JsonSchema)] +pub struct SubmitPrReviewParams { + /// The pull request ID to review (must be positive) + pub pull_request_id: i32, + + /// Review decision: "approve", "approve-with-suggestions", "request-changes", or "comment" + pub event: String, + + /// Review rationale in markdown. Required for "request-changes", optional otherwise. + /// Must be at least 10 characters when provided. + #[serde(default)] + pub body: Option, + + /// Repository alias: "self" for pipeline repo, or an alias from the checkout list. + /// Defaults to "self" if omitted. + #[serde(default)] + pub repository: Option, +} + +impl Validate for SubmitPrReviewParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!( + self.pull_request_id > 0, + "pull_request_id must be a positive integer" + ); + ensure!( + VALID_EVENTS.contains(&self.event.as_str()), + "event must be one of: {}", + VALID_EVENTS.join(", ") + ); + if self.event == "request-changes" { + ensure!( + self.body.is_some(), + "body is required when event is 'request-changes'" + ); + } + if let Some(ref body) = self.body { + ensure!( + body.len() >= 10, + "body must be at least 10 characters" + ); + } + Ok(()) + } +} + +tool_result! { + name = "submit-pr-review", + params = SubmitPrReviewParams, + /// Result of submitting a pull request review + pub struct SubmitPrReviewResult { + pull_request_id: i32, + event: String, + body: Option, + repository: Option, + } +} + +impl Sanitize for SubmitPrReviewResult { + fn sanitize_fields(&mut self) { + self.event = sanitize_text(&self.event); + self.body = self.body.as_deref().map(sanitize_text); + self.repository = self.repository.as_deref().map(sanitize_text); + } +} + +/// Configuration for the submit-pr-review tool (specified in front matter) +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// submit-pr-review: +/// allowed-events: +/// - approve +/// - comment +/// allowed-repositories: +/// - self +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmitPrReviewConfig { + /// Which events are permitted. REQUIRED — empty list rejects all. + #[serde(default, rename = "allowed-events")] + pub allowed_events: Vec, + + /// Which repositories the agent may target. Empty list means all allowed repos. + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, +} + +impl Default for SubmitPrReviewConfig { + fn default() -> Self { + Self { + allowed_events: Vec::new(), + allowed_repositories: Vec::new(), + } + } +} + +#[async_trait::async_trait] +impl Executor for SubmitPrReviewResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!( + "Submitting review on PR #{} — event: {}", + self.pull_request_id, self.event + ); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + let config: SubmitPrReviewConfig = ctx.get_tool_config("submit-pr-review"); + debug!("Config: {:?}", config); + + // Validate event against allowed-events — REQUIRED. + // An empty allowed-events list means the operator hasn't opted in, so reject. + if config.allowed_events.is_empty() { + return Ok(ExecutionResult::failure( + "submit-pr-review requires 'allowed-events' to be configured in \ + safe-outputs.submit-pr-review. This prevents agents from casting \ + unrestricted review votes. Example:\n safe-outputs:\n submit-pr-review:\n \ + allowed-events:\n - comment\n - approve-with-suggestions" + .to_string(), + )); + } + if !config.allowed_events.contains(&self.event) { + return Ok(ExecutionResult::failure(format!( + "Event '{}' is not in the allowed-events list: [{}]", + self.event, + config.allowed_events.join(", ") + ))); + } + + // Validate repository against allowed-repositories config + let repo_alias = self.repository.as_deref().unwrap_or("self"); + if !config.allowed_repositories.is_empty() + && !config.allowed_repositories.contains(&repo_alias.to_string()) + { + return Ok(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list: [{}]", + repo_alias, + config.allowed_repositories.join(", ") + ))); + } + + // Resolve repo name + let repo_name = match resolve_repo_name(self.repository.as_deref(), ctx) { + Ok(name) => name, + Err(failure) => return Ok(failure), + }; + debug!("Resolved repository: {}", repo_name); + + // Map event to vote value + let vote_value = event_to_vote(&self.event).context(format!( + "Invalid event: '{}'. Must be one of: {}", + self.event, + VALID_EVENTS.join(", ") + ))?; + + let client = reqwest::Client::new(); + let encoded_project = utf8_percent_encode(project, PATH_SEGMENT).to_string(); + let encoded_repo = utf8_percent_encode(&repo_name, PATH_SEGMENT).to_string(); + let base_url = format!( + "{}/{}/_apis/git/repositories", + org_url.trim_end_matches('/'), + encoded_project, + ); + + // Resolve the current user identity via connection data. + // Use the org URL — supports vanity domains and national clouds. + let connection_url = format!( + "{}/_apis/connectiondata", + org_url.trim_end_matches('/') + ); + debug!("Connection data URL: {}", connection_url); + + let conn_response = client + .get(&connection_url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to fetch connection data")?; + + if !conn_response.status().is_success() { + let status = conn_response.status(); + let error_body = conn_response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Failed to fetch connection data (HTTP {}): {}", + status, error_body + ))); + } + + let conn_body: serde_json::Value = conn_response + .json() + .await + .context("Failed to parse connection data response")?; + + let user_id = conn_body + .get("authenticatedUser") + .and_then(|au| au.get("id")) + .and_then(|id| id.as_str()) + .context("Connection data response missing authenticatedUser.id")?; + debug!("Authenticated user ID: {}", user_id); + + // Self-approval guard: prevent the agent from approving PRs it created. + // Positive votes (approve=10, approve-with-suggestions=5) are blocked when + // the authenticated user is also the PR author. + if vote_value > 0 { + let pr_url = format!( + "{}/{}/pullRequests/{}?api-version=7.1", + base_url, encoded_repo, self.pull_request_id + ); + let pr_response = client + .get(&pr_url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to fetch PR for self-approval check")?; + + if pr_response.status().is_success() { + let pr_body: serde_json::Value = pr_response + .json() + .await + .context("Failed to parse PR response")?; + + let creator_id = pr_body + .get("createdBy") + .and_then(|cb| cb.get("id")) + .and_then(|id| id.as_str()); + + if creator_id == Some(user_id) { + return Ok(ExecutionResult::failure(format!( + "Self-approval blocked: the authenticated identity created PR #{} \ + and cannot cast a positive vote ('{}') on it", + self.pull_request_id, self.event + ))); + } + } else { + let status = pr_response.status(); + let error_body = pr_response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Failed to fetch PR #{} for self-approval check (HTTP {}): {}", + self.pull_request_id, status, error_body + ))); + } + } + + // PUT vote to reviewers endpoint + let encoded_user_id = utf8_percent_encode(user_id, PATH_SEGMENT).to_string(); + let vote_url = format!( + "{}/{}/pullRequests/{}/reviewers/{}?api-version=7.1", + base_url, encoded_repo, self.pull_request_id, encoded_user_id + ); + let vote_body = serde_json::json!({ + "vote": vote_value + }); + + info!( + "Voting '{}' ({}) on PR #{}", + self.event, vote_value, self.pull_request_id + ); + let response = client + .put(&vote_url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&vote_body) + .send() + .await + .context("Failed to submit vote")?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Failed to submit vote on PR #{} (HTTP {}): {}", + self.pull_request_id, status, error_body + ))); + } + + info!( + "Vote '{}' submitted on PR #{}", + self.event, self.pull_request_id + ); + + // If body is provided, also POST a comment thread with the review rationale + if let Some(ref body) = self.body { + let thread_url = format!( + "{}/{}/pullRequests/{}/threads?api-version=7.1", + base_url, encoded_repo, self.pull_request_id + ); + let thread_body = serde_json::json!({ + "comments": [{ + "parentCommentId": 0, + "content": body, + "commentType": 1 + }], + "status": 1 + }); + + info!( + "Posting review comment on PR #{} ({} chars)", + self.pull_request_id, + body.len() + ); + let thread_response = client + .post(&thread_url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&thread_body) + .send() + .await + .context("Failed to post review comment thread")?; + + if !thread_response.status().is_success() { + let status = thread_response.status(); + let error_body = thread_response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Vote submitted but failed to post review comment on PR #{} (HTTP {}): {}", + self.pull_request_id, status, error_body + ))); + } + + let thread_resp: serde_json::Value = thread_response + .json() + .await + .context("Failed to parse comment thread response")?; + + let thread_id = thread_resp + .get("id") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + info!( + "Review comment thread #{} posted on PR #{}", + thread_id, self.pull_request_id + ); + + return Ok(ExecutionResult::success_with_data( + format!( + "Review '{}' submitted on PR #{} with comment thread #{}", + self.event, self.pull_request_id, thread_id + ), + serde_json::json!({ + "pull_request_id": self.pull_request_id, + "event": self.event, + "vote_value": vote_value, + "thread_id": thread_id, + "repository": repo_name, + }), + )); + } + + Ok(ExecutionResult::success_with_data( + format!( + "Review '{}' submitted on PR #{}", + self.event, self.pull_request_id + ), + serde_json::json!({ + "pull_request_id": self.pull_request_id, + "event": self.event, + "vote_value": vote_value, + "repository": repo_name, + }), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(SubmitPrReviewResult::NAME, "submit-pr-review"); + } + + #[test] + fn test_params_deserializes() { + let json = r#"{"pull_request_id": 42, "event": "approve"}"#; + let params: SubmitPrReviewParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.pull_request_id, 42); + assert_eq!(params.event, "approve"); + assert!(params.body.is_none()); + assert!(params.repository.is_none()); + } + + #[test] + fn test_params_converts_to_result() { + let params = SubmitPrReviewParams { + pull_request_id: 42, + event: "approve".to_string(), + body: None, + repository: Some("self".to_string()), + }; + let result: SubmitPrReviewResult = params.try_into().unwrap(); + assert_eq!(result.name, "submit-pr-review"); + assert_eq!(result.pull_request_id, 42); + assert_eq!(result.event, "approve"); + } + + #[test] + fn test_validation_rejects_zero_pr_id() { + let params = SubmitPrReviewParams { + pull_request_id: 0, + event: "approve".to_string(), + body: None, + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_invalid_event() { + let params = SubmitPrReviewParams { + pull_request_id: 1, + event: "merge".to_string(), + body: None, + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_request_changes_without_body() { + let params = SubmitPrReviewParams { + pull_request_id: 1, + event: "request-changes".to_string(), + body: None, + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_accepts_approve_without_body() { + let params = SubmitPrReviewParams { + pull_request_id: 1, + event: "approve".to_string(), + body: None, + repository: Some("self".to_string()), + }; + let result: Result = params.try_into(); + assert!(result.is_ok()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = SubmitPrReviewParams { + pull_request_id: 99, + event: "request-changes".to_string(), + body: Some("This needs significant rework before merging.".to_string()), + repository: Some("self".to_string()), + }; + let result: SubmitPrReviewResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"submit-pr-review""#)); + assert!(json.contains(r#""pull_request_id":99"#)); + assert!(json.contains(r#""event":"request-changes""#)); + } + + #[test] + fn test_config_defaults() { + let config = SubmitPrReviewConfig::default(); + assert!(config.allowed_events.is_empty()); + assert!(config.allowed_repositories.is_empty()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +allowed-events: + - approve + - comment +allowed-repositories: + - self + - other-repo +"#; + let config: SubmitPrReviewConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.allowed_events, vec!["approve", "comment"]); + assert_eq!(config.allowed_repositories, vec!["self", "other-repo"]); + } +} diff --git a/src/tools/update_pr.rs b/src/tools/update_pr.rs new file mode 100644 index 0000000..aa670cb --- /dev/null +++ b/src/tools/update_pr.rs @@ -0,0 +1,1131 @@ +//! Update pull request safe output tool + +use log::{debug, info, warn}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{PATH_SEGMENT, resolve_repo_name}; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +/// Valid operation names for update-pr +const VALID_OPERATIONS: &[&str] = &[ + "add-reviewers", + "add-labels", + "set-auto-complete", + "vote", + "update-description", +]; + +/// Valid vote values +const VALID_VOTES: &[&str] = &[ + "approve", + "approve-with-suggestions", + "wait-for-author", + "reject", + "reset", +]; + +/// Valid merge strategy values accepted by ADO's completionOptions.mergeStrategy +const VALID_MERGE_STRATEGIES: &[&str] = &["squash", "noFastForward", "rebase", "rebaseMerge"]; + +/// Map a vote string to its ADO numeric value +fn vote_to_ado_value(vote: &str) -> Option { + match vote { + "approve" => Some(10), + "approve-with-suggestions" => Some(5), + "wait-for-author" => Some(-5), + "reject" => Some(-10), + "reset" => Some(0), + _ => None, + } +} + +/// Parameters for updating a pull request +#[derive(Deserialize, JsonSchema)] +pub struct UpdatePrParams { + /// Pull request ID (must be positive) + pub pull_request_id: i32, + + /// Repository alias: "self" for the pipeline repo, or an alias from the checkout list + #[serde(default)] + pub repository: Option, + + /// Operation to perform: "add-reviewers", "add-labels", "set-auto-complete", "vote", or "update-description" + pub operation: String, + + /// Reviewer emails (required for add-reviewers operation) + pub reviewers: Option>, + + /// Label names (required for add-labels operation) + pub labels: Option>, + + /// Vote value: "approve", "approve-with-suggestions", "wait-for-author", "reject", or "reset" + pub vote: Option, + + /// New PR description in markdown (required for update-description, must be >= 10 chars) + pub description: Option, +} + +impl Validate for UpdatePrParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!( + self.pull_request_id > 0, + "pull_request_id must be a positive integer" + ); + ensure!( + VALID_OPERATIONS.contains(&self.operation.as_str()), + "operation must be one of: {}", + VALID_OPERATIONS.join(", ") + ); + + match self.operation.as_str() { + "add-reviewers" => { + let reviewers = self + .reviewers + .as_ref() + .context("reviewers must be provided for add-reviewers operation")?; + ensure!( + !reviewers.is_empty(), + "reviewers list must not be empty for add-reviewers operation" + ); + } + "add-labels" => { + let labels = self + .labels + .as_ref() + .context("labels must be provided for add-labels operation")?; + ensure!( + !labels.is_empty(), + "labels list must not be empty for add-labels operation" + ); + } + "vote" => { + let vote = self + .vote + .as_ref() + .context("vote must be provided for vote operation")?; + ensure!( + VALID_VOTES.contains(&vote.as_str()), + "vote must be one of: {}", + VALID_VOTES.join(", ") + ); + } + "update-description" => { + let desc = self + .description + .as_ref() + .context("description must be provided for update-description operation")?; + ensure!( + desc.len() >= 10, + "description must be at least 10 characters" + ); + } + _ => {} // set-auto-complete has no extra required fields + } + + Ok(()) + } +} + +tool_result! { + name = "update-pr", + params = UpdatePrParams, + /// Result of updating a pull request + pub struct UpdatePrResult { + pull_request_id: i32, + repository: Option, + operation: String, + reviewers: Option>, + labels: Option>, + vote: Option, + description: Option, + } +} + +impl Sanitize for UpdatePrResult { + fn sanitize_fields(&mut self) { + self.repository = self.repository.as_deref().map(sanitize_text); + self.operation = sanitize_text(&self.operation); + self.reviewers = self + .reviewers + .as_ref() + .map(|rs| rs.iter().map(|r| sanitize_text(r)).collect()); + self.labels = self + .labels + .as_ref() + .map(|ls| ls.iter().map(|l| sanitize_text(l)).collect()); + self.vote = self.vote.as_deref().map(sanitize_text); + self.description = self.description.as_deref().map(sanitize_text); + } +} + +/// Configuration for the update-pr tool (specified in front matter) +/// +/// **Allow-list semantics note:** `allowed-operations` and `allowed-repositories` use +/// permissive defaults (empty = all allowed), while `allowed-votes` uses a secure default +/// (empty = all rejected). This asymmetry is intentional — vote operations can auto-approve +/// PRs, so they require explicit opt-in to prevent accidental privilege escalation. +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// update-pr: +/// allowed-operations: +/// - add-reviewers +/// - set-auto-complete +/// allowed-repositories: +/// - self +/// allowed-votes: +/// - approve +/// - reject +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdatePrConfig { + /// Which operations are permitted. Empty list means all operations are allowed. + #[serde(default, rename = "allowed-operations")] + pub allowed_operations: Vec, + + /// Which repositories the agent may target. Empty list means all allowed repos. + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + + /// Which vote values are permitted. REQUIRED for vote operation — + /// empty list rejects all votes to prevent accidental auto-approve. + #[serde(default, rename = "allowed-votes")] + pub allowed_votes: Vec, + + /// Whether to delete the source branch after merge (for set-auto-complete, default: true) + #[serde(default = "default_true", rename = "delete-source-branch")] + pub delete_source_branch: bool, + + /// Merge strategy for auto-complete: "squash", "noFastForward", "rebase", "rebaseMerge" (default: "squash") + #[serde(default = "default_merge_strategy", rename = "merge-strategy")] + pub merge_strategy: String, +} + +fn default_true() -> bool { + true +} + +fn default_merge_strategy() -> String { + "squash".to_string() +} + +impl Default for UpdatePrConfig { + fn default() -> Self { + Self { + allowed_operations: Vec::new(), + allowed_repositories: Vec::new(), + allowed_votes: Vec::new(), + delete_source_branch: true, + merge_strategy: "squash".to_string(), + } + } +} + +#[async_trait::async_trait] +impl Executor for UpdatePrResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!( + "Updating PR #{} — operation: {}", + self.pull_request_id, self.operation + ); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + let config: UpdatePrConfig = ctx.get_tool_config("update-pr"); + debug!("Config: {:?}", config); + + // Validate operation against allowed-operations + if !config.allowed_operations.is_empty() + && !config.allowed_operations.contains(&self.operation) + { + return Ok(ExecutionResult::failure(format!( + "Operation '{}' is not in the allowed-operations list: [{}]", + self.operation, + config.allowed_operations.join(", ") + ))); + } + + // Validate repository against allowed-repositories + let repo_alias = self.repository.as_deref().unwrap_or("self"); + if !config.allowed_repositories.is_empty() + && !config.allowed_repositories.contains(&repo_alias.to_string()) + { + return Ok(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list: [{}]", + repo_alias, + config.allowed_repositories.join(", ") + ))); + } + + // Resolve repo name + let repo_name = match resolve_repo_name(self.repository.as_deref(), ctx) { + Ok(name) => name, + Err(failure) => return Ok(failure), + }; + debug!("Resolved repository: {}", repo_name); + + let client = reqwest::Client::new(); + let encoded_project = utf8_percent_encode(project, PATH_SEGMENT).to_string(); + let base_url = format!( + "{}/{}/_apis/git/repositories", + org_url.trim_end_matches('/'), + encoded_project, + ); + + match self.operation.as_str() { + "set-auto-complete" => { + self.execute_set_auto_complete(&client, &base_url, &repo_name, token, org_url, &config) + .await + } + "vote" => { + self.execute_vote( + &client, + &base_url, + &repo_name, + token, + org_url, + &config, + ) + .await + } + "add-reviewers" => { + self.execute_add_reviewers( + &client, + &base_url, + &repo_name, + token, + org_url, + ) + .await + } + "add-labels" => { + self.execute_add_labels(&client, &base_url, &repo_name, token) + .await + } + "update-description" => { + self.execute_update_description(&client, &base_url, &repo_name, token) + .await + } + _ => Ok(ExecutionResult::failure(format!( + "Unknown operation: {}", + self.operation + ))), + } + } +} + +impl UpdatePrResult { + /// Set auto-complete on a pull request. + /// + /// Resolves the authenticated user identity via `_apis/connectiondata`, then + /// patches the PR with `autoCompleteSetBy` and default completion options. + /// Uses the agent's own identity (not the PR creator) for proper audit trail. + async fn execute_set_auto_complete( + &self, + client: &reqwest::Client, + base_url: &str, + repo_name: &str, + token: &str, + org_url: &str, + config: &UpdatePrConfig, + ) -> anyhow::Result { + // Validate merge_strategy before any network I/O + if !VALID_MERGE_STRATEGIES.contains(&config.merge_strategy.as_str()) { + return Ok(ExecutionResult::failure(format!( + "Invalid merge-strategy '{}'. Must be one of: {}", + config.merge_strategy, + VALID_MERGE_STRATEGIES.join(", ") + ))); + } + + let encoded_repo = utf8_percent_encode(repo_name, PATH_SEGMENT).to_string(); + + // Resolve the agent's identity via connection data + let connection_url = format!( + "{}/_apis/connectiondata", + org_url.trim_end_matches('/') + ); + let conn_response = client + .get(&connection_url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to fetch connection data for auto-complete identity")?; + + if !conn_response.status().is_success() { + let status = conn_response.status(); + let error_body = conn_response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Failed to fetch connection data (HTTP {}): {}", + status, error_body + ))); + } + + let conn_body: serde_json::Value = conn_response + .json() + .await + .context("Failed to parse connection data response")?; + + let agent_user_id = conn_body + .get("authenticatedUser") + .and_then(|au| au.get("id")) + .and_then(|id| id.as_str()) + .context("Connection data response missing authenticatedUser.id")?; + debug!("Agent user ID for auto-complete: {}", agent_user_id); + + // PATCH to set auto-complete using the agent's identity + let patch_url = format!( + "{}/{}/pullRequests/{}?api-version=7.1", + base_url, encoded_repo, self.pull_request_id + ); + let patch_body = serde_json::json!({ + "autoCompleteSetBy": { + "id": agent_user_id + }, + "completionOptions": { + "deleteSourceBranch": config.delete_source_branch, + "mergeStrategy": config.merge_strategy + } + }); + + info!("Setting auto-complete on PR #{}", self.pull_request_id); + let response = client + .patch(&patch_url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&patch_body) + .send() + .await + .context("Failed to set auto-complete on PR")?; + + if response.status().is_success() { + info!( + "Auto-complete set on PR #{}", + self.pull_request_id + ); + Ok(ExecutionResult::success_with_data( + format!("Auto-complete set on PR #{}", self.pull_request_id), + serde_json::json!({ + "pull_request_id": self.pull_request_id, + "operation": "set-auto-complete", + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Ok(ExecutionResult::failure(format!( + "Failed to set auto-complete on PR #{} (HTTP {}): {}", + self.pull_request_id, status, error_body + ))) + } + } + + /// Submit a vote on a pull request. + /// + /// Resolves the current user identity via `_apis/connectiondata`, then + /// PUTs the vote to the reviewers endpoint. + async fn execute_vote( + &self, + client: &reqwest::Client, + base_url: &str, + repo_name: &str, + token: &str, + org_url: &str, + config: &UpdatePrConfig, + ) -> anyhow::Result { + let vote_str = self + .vote + .as_deref() + .context("vote value is required for vote operation")?; + + // Validate against allowed-votes — REQUIRED for vote operation. + // An empty allowed-votes list means the operator hasn't opted in, so reject. + if config.allowed_votes.is_empty() { + return Ok(ExecutionResult::failure( + "vote operation requires 'allowed-votes' to be configured in safe-outputs.update-pr. \ + This prevents agents from casting unrestricted votes (including approve). \ + Example:\n safe-outputs:\n update-pr:\n allowed-votes:\n - approve-with-suggestions\n - wait-for-author" + .to_string(), + )); + } + if !config.allowed_votes.contains(&vote_str.to_string()) + { + return Ok(ExecutionResult::failure(format!( + "Vote '{}' is not in the allowed-votes list: [{}]", + vote_str, + config.allowed_votes.join(", ") + ))); + } + + let vote_value = vote_to_ado_value(vote_str).context(format!( + "Invalid vote value: '{}'. Must be one of: {}", + vote_str, + VALID_VOTES.join(", ") + ))?; + + // Resolve the current user identity. + // Use the org URL for connection data — supports vanity domains and national clouds. + let connection_url = format!( + "{}/_apis/connectiondata", + org_url.trim_end_matches('/') + ); + debug!("Connection data URL: {}", connection_url); + + let conn_response = client + .get(&connection_url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to fetch connection data")?; + + if !conn_response.status().is_success() { + let status = conn_response.status(); + let error_body = conn_response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Failed to fetch connection data (HTTP {}): {}", + status, error_body + ))); + } + + let conn_body: serde_json::Value = conn_response + .json() + .await + .context("Failed to parse connection data response")?; + + let user_id = conn_body + .get("authenticatedUser") + .and_then(|au| au.get("id")) + .and_then(|id| id.as_str()) + .context("Connection data response missing authenticatedUser.id")?; + debug!("Authenticated user ID: {}", user_id); + + // Self-approval guard: prevent the agent from approving PRs it created. + // Positive votes (approve=10, approve-with-suggestions=5) are blocked when + // the authenticated user is also the PR author. + if vote_value > 0 { + let encoded_repo_check = + utf8_percent_encode(repo_name, PATH_SEGMENT).to_string(); + let pr_url = format!( + "{}/{}/pullRequests/{}?api-version=7.1", + base_url, encoded_repo_check, self.pull_request_id + ); + let pr_response = client + .get(&pr_url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to fetch PR for self-approval check")?; + + if pr_response.status().is_success() { + let pr_body: serde_json::Value = pr_response + .json() + .await + .context("Failed to parse PR response")?; + + let creator_id = pr_body + .get("createdBy") + .and_then(|cb| cb.get("id")) + .and_then(|id| id.as_str()); + + if creator_id == Some(user_id) { + return Ok(ExecutionResult::failure(format!( + "Self-approval blocked: the authenticated identity created PR #{} \ + and cannot cast a positive vote ('{}') on it", + self.pull_request_id, vote_str + ))); + } + } else { + let status = pr_response.status(); + let error_body = pr_response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Failed to fetch PR #{} for self-approval check (HTTP {}): {}", + self.pull_request_id, status, error_body + ))); + } + } + + // PUT vote to reviewers endpoint + let encoded_repo = utf8_percent_encode(repo_name, PATH_SEGMENT).to_string(); + let encoded_user_id = utf8_percent_encode(user_id, PATH_SEGMENT).to_string(); + let vote_url = format!( + "{}/{}/pullRequests/{}/reviewers/{}?api-version=7.1", + base_url, encoded_repo, self.pull_request_id, encoded_user_id + ); + let vote_body = serde_json::json!({ + "vote": vote_value + }); + + info!( + "Voting '{}' ({}) on PR #{}", + vote_str, vote_value, self.pull_request_id + ); + let response = client + .put(&vote_url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&vote_body) + .send() + .await + .context("Failed to submit vote")?; + + if response.status().is_success() { + info!( + "Vote '{}' submitted on PR #{}", + vote_str, self.pull_request_id + ); + Ok(ExecutionResult::success_with_data( + format!( + "Vote '{}' submitted on PR #{}", + vote_str, self.pull_request_id + ), + serde_json::json!({ + "pull_request_id": self.pull_request_id, + "operation": "vote", + "vote": vote_str, + "vote_value": vote_value, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Ok(ExecutionResult::failure(format!( + "Failed to submit vote on PR #{} (HTTP {}): {}", + self.pull_request_id, status, error_body + ))) + } + } + + /// Add reviewers to a pull request. + /// + /// For each reviewer email, resolves the identity via VSSPS, then PUTs to + /// the reviewers endpoint with vote 0. + async fn execute_add_reviewers( + &self, + client: &reqwest::Client, + base_url: &str, + repo_name: &str, + token: &str, + org_url: &str, + ) -> anyhow::Result { + let reviewers = self + .reviewers + .as_ref() + .context("reviewers list is required for add-reviewers operation")?; + + let encoded_repo = utf8_percent_encode(repo_name, PATH_SEGMENT).to_string(); + let mut added = Vec::new(); + let mut failed = Vec::new(); + + // Derive VSSPS base URL once, before the loop. + let trimmed_org = org_url.trim_end_matches('/'); + let vssps_base = trimmed_org + .replace("://dev.azure.com/", "://vssps.dev.azure.com/"); + if vssps_base == trimmed_org { + return Ok(ExecutionResult::failure(format!( + "Cannot derive VSSPS identity endpoint from org URL '{}'. \ + The add-reviewers operation requires dev.azure.com-style URLs \ + to resolve reviewer identities. Legacy *.visualstudio.com \ + organizations are not currently supported for this operation.", + trimmed_org + ))); + } + + for reviewer in reviewers { + let identity_url = format!( + "{}/_apis/identities?searchFilter=General&filterValue={}&api-version=7.1", + vssps_base, + utf8_percent_encode(reviewer, PATH_SEGMENT), + ); + debug!("Resolving identity for '{}': {}", reviewer, identity_url); + + let identity_response = client + .get(&identity_url) + .basic_auth("", Some(token)) + .send() + .await; + + let reviewer_id = match identity_response { + Ok(resp) if resp.status().is_success() => { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + body.get("value") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|entry| entry.get("id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) + } + Ok(resp) => { + warn!( + "Identity lookup for '{}' returned HTTP {}", + reviewer, + resp.status() + ); + None + } + Err(e) => { + warn!("Identity lookup for '{}' failed: {}", reviewer, e); + None + } + }; + + let reviewer_id = match reviewer_id { + Some(id) => id, + None => { + warn!( + "Could not resolve identity for '{}', skipping", + reviewer + ); + failed.push(format!("{} (identity not found)", reviewer)); + continue; + } + }; + + let reviewer_url = format!( + "{}/{}/pullRequests/{}/reviewers/{}?api-version=7.1", + base_url, + encoded_repo, + self.pull_request_id, + reviewer_id, + ); + let reviewer_body = serde_json::json!({ + "vote": 0, + "isRequired": false + }); + + debug!("Adding reviewer '{}' to PR #{}", reviewer, self.pull_request_id); + let response = client + .put(&reviewer_url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&reviewer_body) + .send() + .await; + + match response { + Ok(resp) if resp.status().is_success() => { + info!("Added reviewer '{}' to PR #{}", reviewer, self.pull_request_id); + added.push(reviewer.clone()); + } + Ok(resp) => { + let status = resp.status(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + warn!( + "Failed to add reviewer '{}' to PR #{} (HTTP {}): {}", + reviewer, self.pull_request_id, status, error_body + ); + failed.push(format!("{} (HTTP {})", reviewer, status)); + } + Err(e) => { + warn!( + "Request failed for reviewer '{}' on PR #{}: {}", + reviewer, self.pull_request_id, e + ); + failed.push(format!("{} (request error)", reviewer)); + } + } + } + + if added.is_empty() && !failed.is_empty() { + Ok(ExecutionResult::failure(format!( + "Failed to add any reviewers to PR #{}: {}", + self.pull_request_id, + failed.join(", ") + ))) + } else { + let mut message = format!( + "Added {} reviewer(s) to PR #{}", + added.len(), + self.pull_request_id + ); + if !failed.is_empty() { + message.push_str(&format!( + " ({} failed: {})", + failed.len(), + failed.join(", ") + )); + } + Ok(ExecutionResult::success_with_data( + message, + serde_json::json!({ + "pull_request_id": self.pull_request_id, + "operation": "add-reviewers", + "added": added, + "failed": failed, + }), + )) + } + } + + /// Add labels to a pull request. + /// + /// For each label, POSTs to the labels endpoint. + async fn execute_add_labels( + &self, + client: &reqwest::Client, + base_url: &str, + repo_name: &str, + token: &str, + ) -> anyhow::Result { + let labels = self + .labels + .as_ref() + .context("labels list is required for add-labels operation")?; + + let encoded_repo = utf8_percent_encode(repo_name, PATH_SEGMENT).to_string(); + let labels_url = format!( + "{}/{}/pullRequests/{}/labels?api-version=7.1", + base_url, encoded_repo, self.pull_request_id + ); + + let mut added = Vec::new(); + let mut failed = Vec::new(); + + for label in labels { + let label_body = serde_json::json!({ + "name": label + }); + + debug!( + "Adding label '{}' to PR #{}", + label, self.pull_request_id + ); + let response = client + .post(&labels_url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&label_body) + .send() + .await; + + match response { + Ok(resp) if resp.status().is_success() => { + info!("Added label '{}' to PR #{}", label, self.pull_request_id); + added.push(label.clone()); + } + Ok(resp) => { + let status = resp.status(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + warn!( + "Failed to add label '{}' to PR #{} (HTTP {}): {}", + label, self.pull_request_id, status, error_body + ); + failed.push(format!("{} (HTTP {})", label, status)); + } + Err(e) => { + warn!( + "Request failed for label '{}' on PR #{}: {}", + label, self.pull_request_id, e + ); + failed.push(format!("{} (request error)", label)); + } + } + } + + if added.is_empty() && !failed.is_empty() { + Ok(ExecutionResult::failure(format!( + "Failed to add any labels to PR #{}: {}", + self.pull_request_id, + failed.join(", ") + ))) + } else { + let mut message = format!( + "Added {} label(s) to PR #{}", + added.len(), + self.pull_request_id + ); + if !failed.is_empty() { + message.push_str(&format!( + " ({} failed: {})", + failed.len(), + failed.join(", ") + )); + } + Ok(ExecutionResult::success_with_data( + message, + serde_json::json!({ + "pull_request_id": self.pull_request_id, + "operation": "add-labels", + "added": added, + "failed": failed, + }), + )) + } + } + + /// Update the description of a pull request. + async fn execute_update_description( + &self, + client: &reqwest::Client, + base_url: &str, + repo_name: &str, + token: &str, + ) -> anyhow::Result { + let description = self + .description + .as_ref() + .context("description is required for update-description operation")?; + + let encoded_repo = utf8_percent_encode(repo_name, PATH_SEGMENT).to_string(); + let patch_url = format!( + "{}/{}/pullRequests/{}?api-version=7.1", + base_url, encoded_repo, self.pull_request_id + ); + let patch_body = serde_json::json!({ + "description": description + }); + + info!( + "Updating description on PR #{} ({} chars)", + self.pull_request_id, + description.len() + ); + let response = client + .patch(&patch_url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&patch_body) + .send() + .await + .context("Failed to update PR description")?; + + if response.status().is_success() { + info!("Description updated on PR #{}", self.pull_request_id); + Ok(ExecutionResult::success_with_data( + format!( + "Description updated on PR #{}", + self.pull_request_id + ), + serde_json::json!({ + "pull_request_id": self.pull_request_id, + "operation": "update-description", + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Ok(ExecutionResult::failure(format!( + "Failed to update description on PR #{} (HTTP {}): {}", + self.pull_request_id, status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(UpdatePrResult::NAME, "update-pr"); + } + + #[test] + fn test_params_deserializes() { + let json = r#"{ + "pull_request_id": 42, + "operation": "set-auto-complete" + }"#; + let params: UpdatePrParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.pull_request_id, 42); + assert_eq!(params.operation, "set-auto-complete"); + assert!(params.repository.is_none()); + } + + #[test] + fn test_params_converts_to_result() { + let params = UpdatePrParams { + pull_request_id: 42, + repository: Some("self".to_string()), + operation: "set-auto-complete".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: UpdatePrResult = params.try_into().unwrap(); + assert_eq!(result.name, "update-pr"); + assert_eq!(result.pull_request_id, 42); + assert_eq!(result.operation, "set-auto-complete"); + } + + #[test] + fn test_validation_rejects_zero_pr_id() { + let params = UpdatePrParams { + pull_request_id: 0, + repository: None, + operation: "set-auto-complete".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_invalid_operation() { + let params = UpdatePrParams { + pull_request_id: 1, + repository: None, + operation: "delete-pr".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_vote_without_value() { + let params = UpdatePrParams { + pull_request_id: 1, + repository: None, + operation: "vote".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_reviewers_without_list() { + let params = UpdatePrParams { + pull_request_id: 1, + repository: None, + operation: "add-reviewers".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = UpdatePrParams { + pull_request_id: 99, + repository: Some("self".to_string()), + operation: "vote".to_string(), + reviewers: None, + labels: None, + vote: Some("approve".to_string()), + description: None, + }; + let result: UpdatePrResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"update-pr""#)); + assert!(json.contains(r#""pull_request_id":99"#)); + assert!(json.contains(r#""operation":"vote""#)); + } + + #[test] + fn test_config_defaults() { + let config = UpdatePrConfig::default(); + assert!(config.allowed_operations.is_empty()); + assert!(config.allowed_repositories.is_empty()); + assert!(config.allowed_votes.is_empty()); + assert_eq!(config.merge_strategy, "squash"); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +allowed-operations: + - add-reviewers + - set-auto-complete +allowed-repositories: + - self +allowed-votes: + - approve + - reject +"#; + let config: UpdatePrConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.allowed_operations.len(), 2); + assert!(config.allowed_operations.contains(&"add-reviewers".to_string())); + assert!(config.allowed_operations.contains(&"set-auto-complete".to_string())); + assert_eq!(config.allowed_repositories.len(), 1); + assert_eq!(config.allowed_votes.len(), 2); + } + + #[test] + fn test_valid_merge_strategies_are_expected_values() { + assert_eq!( + VALID_MERGE_STRATEGIES, + &["squash", "noFastForward", "rebase", "rebaseMerge"] + ); + } + + #[test] + fn test_config_deserializes_merge_strategy() { + let yaml = r#"merge-strategy: rebase"#; + let config: UpdatePrConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.merge_strategy, "rebase"); + } + + #[test] + fn test_valid_merge_strategies_are_recognized() { + for strategy in VALID_MERGE_STRATEGIES { + assert!( + VALID_MERGE_STRATEGIES.contains(strategy), + "'{}' should be a valid merge strategy", + strategy + ); + } + // Ensure invalid strategy is NOT in the list + assert!(!VALID_MERGE_STRATEGIES.contains(&"invalid")); + assert!(!VALID_MERGE_STRATEGIES.contains(&"Squash")); + } +} diff --git a/src/tools/update_wiki_page.rs b/src/tools/update_wiki_page.rs index 6c00757..42eb5cc 100644 --- a/src/tools/update_wiki_page.rs +++ b/src/tools/update_wiki_page.rs @@ -7,6 +7,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::PATH_SEGMENT; +use super::resolve_wiki_branch; use crate::sanitize::{Sanitize, sanitize as sanitize_text}; use crate::tool_result; use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; @@ -104,6 +105,12 @@ pub struct UpdateWikiPageConfig { #[serde(default, rename = "wiki-project")] pub wiki_project: Option, + /// Git branch for the wiki. Required for **code wikis** (type 1) where the + /// ADO API demands an explicit `versionDescriptor`. For project wikis this + /// can be omitted (defaults to `wikiMaster` server-side). + #[serde(default)] + pub branch: Option, + /// Security restriction: the agent may only write wiki pages whose paths /// start with this prefix (e.g. `"/agent-output"`). Paths that do not match /// are rejected at execution time. When omitted, no restriction is applied. @@ -227,13 +234,34 @@ impl Executor for UpdateWikiPageResult { let client = reqwest::Client::new(); + // Resolve the effective branch: explicit config → auto-detect from wiki + // metadata (code wikis need an explicit versionDescriptor). + let resolved_branch = match resolve_wiki_branch( + &client, + org_url, + project, + wiki_name, + token, + config.branch.as_deref(), + ) + .await + { + Ok(b) => b, + Err(msg) => return Ok(ExecutionResult::failure(msg)), + }; + // ── GET: check whether the page exists and obtain its ETag ──────────── + let mut get_query: Vec<(&str, &str)> = vec![ + ("path", effective_path.as_str()), + ("api-version", "7.0"), + ]; + if let Some(branch) = &resolved_branch { + get_query.push(("versionDescriptor.version", branch.as_str())); + get_query.push(("versionDescriptor.versionType", "branch")); + } let get_resp = client .get(&base_url) - .query(&[ - ("path", effective_path.as_str()), - ("api-version", "7.0"), - ]) + .query(&get_query) .basic_auth("", Some(token)) .send() .await @@ -272,13 +300,18 @@ impl Executor for UpdateWikiPageResult { debug!("Updating existing wiki page: {effective_path}"); // ── PUT: create or update the page ──────────────────────────────────── + let mut put_query: Vec<(&str, &str)> = vec![ + ("path", effective_path.as_str()), + ("comment", comment), + ("api-version", "7.0"), + ]; + if let Some(branch) = &resolved_branch { + put_query.push(("versionDescriptor.version", branch.as_str())); + put_query.push(("versionDescriptor.versionType", "branch")); + } let mut put_req = client .put(&base_url) - .query(&[ - ("path", effective_path.as_str()), - ("comment", comment), - ("api-version", "7.0"), - ]) + .query(&put_query) .header("Content-Type", "application/json") .basic_auth("", Some(token)) .json(&serde_json::json!({ "content": self.content })); @@ -465,6 +498,7 @@ mod tests { let config = UpdateWikiPageConfig::default(); assert!(config.wiki_name.is_none()); assert!(config.wiki_project.is_none()); + assert!(config.branch.is_none()); assert!(config.path_prefix.is_none()); assert!(config.title_prefix.is_none()); assert!(config.comment.is_none()); @@ -482,11 +516,23 @@ comment: "Updated by agent" let config: UpdateWikiPageConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(config.wiki_name.as_deref(), Some("MyProject.wiki")); assert_eq!(config.wiki_project.as_deref(), Some("OtherProject")); + assert!(config.branch.is_none()); assert_eq!(config.path_prefix.as_deref(), Some("/agent-output")); assert_eq!(config.title_prefix.as_deref(), Some("[Agent] ")); assert_eq!(config.comment.as_deref(), Some("Updated by agent")); } + #[test] + fn test_config_deserializes_with_branch() { + let yaml = r#" +wiki-name: "Azure Sphere" +branch: "main" +"#; + let config: UpdateWikiPageConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.wiki_name.as_deref(), Some("Azure Sphere")); + assert_eq!(config.branch.as_deref(), Some("main")); + } + #[test] fn test_config_partial_deserialize_uses_defaults() { let yaml = r#" diff --git a/src/tools/upload_attachment.rs b/src/tools/upload_attachment.rs new file mode 100644 index 0000000..b258dfa --- /dev/null +++ b/src/tools/upload_attachment.rs @@ -0,0 +1,528 @@ +//! Upload attachment safe output tool + +use log::{debug, info, warn}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::PATH_SEGMENT; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use anyhow::{Context, ensure}; + +/// Parameters for uploading an attachment to a work item +#[derive(Deserialize, JsonSchema)] +pub struct UploadAttachmentParams { + /// The work item ID to attach the file to + pub work_item_id: i64, + + /// Path to the file in the workspace to upload. Must be a relative path with no directory traversal. + pub file_path: String, + + /// Optional description of the attachment. Must be at least 3 characters if provided. + pub comment: Option, +} + +impl Validate for UploadAttachmentParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!(self.work_item_id > 0, "work_item_id must be positive"); + ensure!(!self.file_path.is_empty(), "file_path must not be empty"); + ensure!( + !self.file_path.split(['/', '\\']).any(|component| component == ".."), + "file_path must not contain '..' path components" + ); + ensure!( + !self.file_path.starts_with('/') && !self.file_path.starts_with('\\'), + "file_path must not be an absolute path" + ); + ensure!( + !self.file_path.contains(':'), + "file_path must not contain ':'" + ); + ensure!( + !self.file_path.contains('\0'), + "file_path must not contain null bytes" + ); + ensure!( + !self + .file_path + .split(['/', '\\']) + .any(|component| component == ".git"), + "file_path must not contain '.git' components" + ); + if let Some(comment) = &self.comment { + ensure!( + comment.len() >= 3, + "comment must be at least 3 characters" + ); + } + Ok(()) + } +} + +tool_result! { + name = "upload-attachment", + params = UploadAttachmentParams, + /// Result of uploading an attachment to a work item + pub struct UploadAttachmentResult { + work_item_id: i64, + file_path: String, + comment: Option, + } +} + +impl Sanitize for UploadAttachmentResult { + fn sanitize_fields(&mut self) { + if let Some(comment) = &self.comment { + self.comment = Some(sanitize_text(comment)); + } + } +} + +const DEFAULT_MAX_FILE_SIZE: u64 = 5 * 1024 * 1024; // 5 MB + +/// Configuration for the upload-attachment tool (specified in front matter) +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// upload-attachment: +/// max-file-size: 5242880 +/// allowed-extensions: +/// - .png +/// - .pdf +/// - .log +/// comment-prefix: "[Agent] " +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadAttachmentConfig { + /// Maximum file size in bytes (default: 5 MB) + #[serde(default = "default_max_file_size", rename = "max-file-size")] + pub max_file_size: u64, + + /// Allowed file extensions (e.g., [".png", ".pdf"]). Empty means all extensions allowed. + #[serde(default, rename = "allowed-extensions")] + pub allowed_extensions: Vec, + + /// Prefix to prepend to the comment + #[serde(default, rename = "comment-prefix")] + pub comment_prefix: Option, +} + +fn default_max_file_size() -> u64 { + DEFAULT_MAX_FILE_SIZE +} + +impl Default for UploadAttachmentConfig { + fn default() -> Self { + Self { + max_file_size: DEFAULT_MAX_FILE_SIZE, + allowed_extensions: Vec::new(), + comment_prefix: None, + } + } +} + +#[async_trait::async_trait] +impl Executor for UploadAttachmentResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!( + "Uploading attachment '{}' to work item #{}", + self.file_path, self.work_item_id + ); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + debug!("ADO org: {}, project: {}", org_url, project); + + let config: UploadAttachmentConfig = ctx.get_tool_config("upload-attachment"); + debug!("Max file size: {} bytes", config.max_file_size); + debug!("Allowed extensions: {:?}", config.allowed_extensions); + + // Validate file extension against allowed-extensions (if configured) + if !config.allowed_extensions.is_empty() { + let has_valid_ext = config.allowed_extensions.iter().any(|ext| { + self.file_path + .to_lowercase() + .ends_with(&ext.to_lowercase()) + }); + if !has_valid_ext { + return Ok(ExecutionResult::failure(format!( + "File '{}' has an extension not in the allowed list: {:?}", + self.file_path, config.allowed_extensions + ))); + } + } + + // Resolve file path relative to source_directory + let resolved_path = ctx.source_directory.join(&self.file_path); + debug!("Resolved file path: {}", resolved_path.display()); + + // Canonicalize to resolve symlinks, then verify the path stays within source_directory. + let canonical = resolved_path + .canonicalize() + .context("Failed to canonicalize file path — file may not exist or contains broken symlinks")?; + let canonical_base = ctx + .source_directory + .canonicalize() + .context("Failed to canonicalize source directory")?; + if !canonical.starts_with(&canonical_base) { + return Ok(ExecutionResult::failure(format!( + "File path '{}' resolves outside the workspace (symlink escape)", + self.file_path + ))); + } + + // Check file size + let metadata = std::fs::metadata(&canonical) + .context("Failed to read file metadata")?; + let file_size = metadata.len(); + debug!("File size: {} bytes", file_size); + if file_size > config.max_file_size { + return Ok(ExecutionResult::failure(format!( + "File size ({} bytes) exceeds maximum allowed size ({} bytes)", + file_size, config.max_file_size + ))); + } + + // Read file bytes + let file_bytes = + std::fs::read(&canonical).context("Failed to read file contents")?; + + // Check if file is text (valid UTF-8) — if text, scan for ##vso[ command injection. + // Binary files (where from_utf8 fails) skip this check intentionally: ADO's attachment + // viewer won't execute ##vso[ sequences from binary content. Note that a binary file + // with a valid UTF-8 preamble but malformed tail will also skip the scan, but this is + // acceptable because the injection risk from binary attachments is negligible. + if let Ok(text) = std::str::from_utf8(&file_bytes) { + if text.contains("##vso[") { + return Ok(ExecutionResult::failure(format!( + "File '{}' contains '##vso[' command injection sequence", + self.file_path + ))); + } + } + + // Extract filename for upload + let filename = std::path::Path::new(&self.file_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("attachment"); + debug!("Upload filename: {}", filename); + + // Apply comment-prefix to comment if configured + let effective_comment = match (&self.comment, &config.comment_prefix) { + (Some(c), Some(prefix)) => format!("{}{}", prefix, c), + (Some(c), None) => c.clone(), + (None, _) => "Uploaded by agent".to_string(), + }; + debug!("Effective comment: {}", effective_comment); + + let client = reqwest::Client::new(); + + // Step 1: Upload file + // POST {org_url}/{project}/_apis/wit/attachments?fileName={filename}&api-version=7.1 + let upload_url = format!( + "{}/{}/_apis/wit/attachments?fileName={}&api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + utf8_percent_encode(filename, PATH_SEGMENT), + ); + debug!("Upload URL: {}", upload_url); + + info!("Uploading file '{}' ({} bytes)", filename, file_size); + let upload_response = client + .post(&upload_url) + .header("Content-Type", "application/octet-stream") + .basic_auth("", Some(token)) + .body(file_bytes) + .send() + .await + .context("Failed to upload attachment to Azure DevOps")?; + + if !upload_response.status().is_success() { + let status = upload_response.status(); + let error_body = upload_response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Failed to upload attachment (HTTP {}): {}", + status, error_body + ))); + } + + let upload_body: serde_json::Value = upload_response + .json() + .await + .context("Failed to parse upload response JSON")?; + + let attachment_url = upload_body + .get("url") + .and_then(|v| v.as_str()) + .context("Upload response missing 'url' field")? + .to_string(); + debug!("Attachment URL: {}", attachment_url); + + // Step 2: Link attachment to work item + // PATCH {org_url}/{project}/_apis/wit/workitems/{work_item_id}?api-version=7.1 + let link_url = format!( + "{}/{}/_apis/wit/workitems/{}?api-version=7.1", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + self.work_item_id, + ); + debug!("Link URL: {}", link_url); + + let patch_doc = serde_json::json!([{ + "op": "add", + "path": "/relations/-", + "value": { + "rel": "AttachedFile", + "url": attachment_url, + "attributes": { + "comment": effective_comment, + } + } + }]); + + info!( + "Linking attachment to work item #{}", + self.work_item_id + ); + let link_response = client + .patch(&link_url) + .header("Content-Type", "application/json-patch+json") + .basic_auth("", Some(token)) + .json(&patch_doc) + .send() + .await + .context("Failed to link attachment to work item")?; + + if link_response.status().is_success() { + info!( + "Attachment '{}' linked to work item #{}", + filename, self.work_item_id + ); + + Ok(ExecutionResult::success_with_data( + format!( + "Uploaded '{}' and linked to work item #{}", + filename, self.work_item_id + ), + serde_json::json!({ + "work_item_id": self.work_item_id, + "file_path": self.file_path, + "attachment_url": attachment_url, + "project": project, + }), + )) + } else { + let status = link_response.status(); + let error_body = link_response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + warn!( + "Attachment uploaded but linking failed — the attachment at {} is orphaned", + attachment_url + ); + + Ok(ExecutionResult::failure(format!( + "Attachment uploaded but failed to link to work item #{} (HTTP {}): {}", + self.work_item_id, status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(UploadAttachmentResult::NAME, "upload-attachment"); + } + + #[test] + fn test_params_deserializes() { + let json = + r#"{"work_item_id": 42, "file_path": "output/report.pdf", "comment": "Weekly report"}"#; + let params: UploadAttachmentParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.work_item_id, 42); + assert_eq!(params.file_path, "output/report.pdf"); + assert_eq!(params.comment, Some("Weekly report".to_string())); + } + + #[test] + fn test_params_converts_to_result() { + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "output/report.pdf".to_string(), + comment: Some("Weekly report".to_string()), + }; + let result: UploadAttachmentResult = params.try_into().unwrap(); + assert_eq!(result.name, "upload-attachment"); + assert_eq!(result.work_item_id, 42); + assert_eq!(result.file_path, "output/report.pdf"); + assert_eq!(result.comment, Some("Weekly report".to_string())); + } + + #[test] + fn test_validation_rejects_zero_work_item_id() { + let params = UploadAttachmentParams { + work_item_id: 0, + file_path: "output/report.pdf".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_empty_file_path() { + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_path_traversal() { + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "../etc/passwd".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_embedded_traversal() { + // "src/../secret" has ".." as a standalone component + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "src/../secret".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_backslash_traversal() { + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "src\\..\\secret.txt".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_backslash_absolute_path() { + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "\\etc\\passwd".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_accepts_filename_with_dots_in_name() { + // "report..v2.pdf" has ".." inside a filename, not as a standalone component + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "report..v2.pdf".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_ok(), "report..v2.pdf should be a valid filename"); + } + + #[test] + fn test_validation_accepts_directory_with_dots_in_name() { + // "v2..3/notes.md" — ".." inside a directory name, not a standalone component + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "v2..3/notes.md".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_ok(), "v2..3/notes.md should be valid"); + } + + #[test] + fn test_validation_rejects_absolute_path() { + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "/etc/passwd".to_string(), + comment: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = UploadAttachmentParams { + work_item_id: 42, + file_path: "output/report.pdf".to_string(), + comment: Some("Test attachment".to_string()), + }; + let result: UploadAttachmentResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"upload-attachment""#)); + assert!(json.contains(r#""work_item_id":42"#)); + assert!(json.contains(r#""file_path":"output/report.pdf""#)); + } + + #[test] + fn test_config_defaults() { + let config = UploadAttachmentConfig::default(); + assert_eq!(config.max_file_size, 5 * 1024 * 1024); + assert!(config.allowed_extensions.is_empty()); + assert!(config.comment_prefix.is_none()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +max-file-size: 1048576 +allowed-extensions: + - .png + - .pdf + - .log +comment-prefix: "[Agent] " +"#; + let config: UploadAttachmentConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.max_file_size, 1_048_576); + assert_eq!( + config.allowed_extensions, + vec![".png", ".pdf", ".log"] + ); + assert_eq!(config.comment_prefix, Some("[Agent] ".to_string())); + } +} diff --git a/templates/1es-base.yml b/templates/1es-base.yml index ca1385c..4d9b83d 100644 --- a/templates/1es-base.yml +++ b/templates/1es-base.yml @@ -43,6 +43,7 @@ extends: - job: PerformAgenticTask displayName: "{{ agent_name }} (Agent)" {{ agentic_depends_on }} + {{ job_timeout }} templateContext: type: agencyJob arguments: @@ -75,7 +76,7 @@ extends: - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" - $AGENTIC_PIPELINES_PATH check "{{ source_path }}" "{{ pipeline_path }}" + $AGENTIC_PIPELINES_PATH check "{{ pipeline_path }}" displayName: "Verify pipeline integrity" - bash: | @@ -128,9 +129,9 @@ extends: postAgentSteps: {{ finalize_steps }} globalOptions: '--log-dir $(Agency_LogPath) {{ global_options }}' - commandOptions: '{{ agency_params }}' + commandOptions: '{{ copilot_params }}' logLevel: '{{ log_level }}' - logPath: '$(Build.StagingDirectory)/agency-logs' + logPath: '$(Build.StagingDirectory)/copilot-logs' createArtifact: true mcpConfiguration: {{ mcp_configuration }} @@ -230,7 +231,7 @@ extends: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Use $(cat file) like gh-aw does - the command is executed directly, not via a variable - copilot --prompt "$(cat $(Agent.TempDirectory)/threat-analysis-prompt.md)" {{ agency_params }} > "$THREAT_OUTPUT_FILE" 2>&1 + copilot --prompt "$(cat $(Agent.TempDirectory)/threat-analysis-prompt.md)" {{ copilot_params }} > "$THREAT_OUTPUT_FILE" 2>&1 AGENT_EXIT_CODE=$? echo "=== Threat Analysis Output (sanitized) ===" diff --git a/templates/base.yml b/templates/base.yml index 04ee0aa..cb85e60 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -17,6 +17,7 @@ jobs: - job: PerformAgenticTask displayName: "{{ agent_name }} (Agent Automations)" {{ agentic_depends_on }} + {{ job_timeout }} pool: name: {{ pool }} steps: @@ -73,7 +74,7 @@ jobs: - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" - $AGENTIC_PIPELINES_PATH check "{{ source_path }}" "{{ pipeline_path }}" + $AGENTIC_PIPELINES_PATH check "{{ pipeline_path }}" displayName: "Verify pipeline integrity" - bash: | @@ -169,6 +170,8 @@ jobs: - task: DockerInstaller@0 displayName: "Install Docker" + inputs: + dockerVersion: 26.1.4 - bash: | AWF_VERSION="{{ firewall_version }}" @@ -304,7 +307,7 @@ jobs: --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json {{ agency_params }}' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json {{ copilot_params }}' \ > "$AGENT_OUTPUT_FILE" 2>&1 \ && AGENT_EXIT_CODE=0 || AGENT_EXIT_CODE=$? @@ -359,9 +362,6 @@ jobs: if [ -d ~/.copilot/logs ]; then cp -r ~/.copilot/logs/* "$(Agent.TempDirectory)/staging/logs/" 2>/dev/null || true fi - if [ -d ~/.agency/logs ]; then - cp -r ~/.agency/logs/* "$(Agent.TempDirectory)/staging/logs/" 2>/dev/null || true - fi if [ -d ~/.ado-aw/logs ]; then cp -r ~/.ado-aw/logs/* "$(Agent.TempDirectory)/staging/logs/" 2>/dev/null || true fi @@ -430,6 +430,8 @@ jobs: - task: DockerInstaller@0 displayName: "Install Docker" + inputs: + dockerVersion: 26.1.4 - bash: | AWF_VERSION="{{ firewall_version }}" @@ -489,7 +491,7 @@ jobs: --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" {{ agency_params }}' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" {{ copilot_params }}' \ > "$THREAT_OUTPUT_FILE" 2>&1 AGENT_EXIT_CODE=$? @@ -574,10 +576,6 @@ jobs: mkdir -p "$(Agent.TempDirectory)/analyzed_outputs/logs/copilot" cp -r ~/.copilot/logs/* "$(Agent.TempDirectory)/analyzed_outputs/logs/copilot/" 2>/dev/null || true fi - if [ -d ~/.agency/logs ]; then - mkdir -p "$(Agent.TempDirectory)/analyzed_outputs/logs/agency" - cp -r ~/.agency/logs/* "$(Agent.TempDirectory)/analyzed_outputs/logs/agency/" 2>/dev/null || true - fi if [ -d ~/.ado-aw/logs ]; then mkdir -p "$(Agent.TempDirectory)/analyzed_outputs/logs/ado-aw" cp -r ~/.ado-aw/logs/* "$(Agent.TempDirectory)/analyzed_outputs/logs/ado-aw/" 2>/dev/null || true @@ -649,10 +647,6 @@ jobs: mkdir -p "$(Agent.TempDirectory)/staging/logs/copilot" cp -r ~/.copilot/logs/* "$(Agent.TempDirectory)/staging/logs/copilot/" 2>/dev/null || true fi - if [ -d ~/.agency/logs ]; then - mkdir -p "$(Agent.TempDirectory)/staging/logs/agency" - cp -r ~/.agency/logs/* "$(Agent.TempDirectory)/staging/logs/agency/" 2>/dev/null || true - fi if [ -d ~/.ado-aw/logs ]; then mkdir -p "$(Agent.TempDirectory)/staging/logs/ado-aw" cp -r ~/.ado-aw/logs/* "$(Agent.TempDirectory)/staging/logs/ado-aw/" 2>/dev/null || true diff --git a/tests/EXAMPLES.md b/tests/EXAMPLES.md index dfa694b..8fb0e03 100644 --- a/tests/EXAMPLES.md +++ b/tests/EXAMPLES.md @@ -78,10 +78,12 @@ fn test_with_hashmap() { mcps.insert("ado".to_string(), McpConfig::Enabled(true)); mcps.insert("es-chat".to_string(), McpConfig::Enabled(true)); - let result = generate_agency_params(&mcps); + let result = generate_copilot_params(&mcps); assert!(result.contains("--prompt")); - assert!(result.contains("--mcp ado") || result.contains("--mcp es-chat")); + // MCPs are handled via the MCP firewall, not --mcp flags + assert!(!result.contains("--mcp ado")); + assert!(!result.contains("--mcp es-chat")); } ``` @@ -101,7 +103,7 @@ fn test_with_options() { }), ); - let result = generate_agency_params(&mcps); + let result = generate_copilot_params(&mcps); assert!(!result.contains("--mcp custom-tool")); } diff --git a/tests/QUICKREF.md b/tests/QUICKREF.md index 5b9a023..186f3b0 100644 --- a/tests/QUICKREF.md +++ b/tests/QUICKREF.md @@ -49,7 +49,7 @@ ado-aw/ ✅ Schedule generation (hourly/daily) ✅ Repository configuration ✅ Checkout steps -✅ Agency parameters +✅ Copilot parameters ✅ Markdown parsing ✅ Error handling ✅ Edge cases diff --git a/tests/README.md b/tests/README.md index 4b5e3bf..ac53fc6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -68,8 +68,8 @@ Tests checkout step generation for: - Empty repository list - Multiple repositories -### `test_generate_agency_params_*` (3 tests) -Tests agency parameter generation for: +### `test_generate_copilot_params_*` (3 tests) +Tests copilot parameter generation for: - Built-in MCPs (enabled) - Built-in MCPs (disabled) - Custom MCPs (should be skipped) @@ -101,7 +101,7 @@ Verifies that the base template contains all required markers: - `{{ checkout_repositories }}` - `{{ agent }}` - `{{ agent_name }}` -- `{{ agency_params }}` +- `{{ copilot_params }}` ### `test_example_file_structure` Validates the example file (`examples/sample-agent.md`) to ensure: @@ -126,7 +126,7 @@ The current test suite provides coverage for: - ✅ Schedule generation - ✅ Repository configuration generation - ✅ Checkout step generation -- ✅ Agency parameter generation +- ✅ Copilot parameter generation - ✅ Markdown parsing - ✅ Template structure validation - ✅ Example file validation diff --git a/tests/SUMMARY.md b/tests/SUMMARY.md index bf14ee3..13e775b 100644 --- a/tests/SUMMARY.md +++ b/tests/SUMMARY.md @@ -27,10 +27,10 @@ Added 18 comprehensive unit tests in the `tests` module at the end of `main.rs`: - `test_generate_checkout_steps_empty` - Tests empty checkout list - `test_generate_checkout_steps_multiple` - Tests multiple checkout steps -#### Agency Parameters (3 tests) -- `test_generate_agency_params_builtin_enabled` - Tests enabled built-in MCPs -- `test_generate_agency_params_builtin_disabled` - Tests disabled MCPs -- `test_generate_agency_params_custom_mcp_skipped` - Verifies custom MCPs are skipped +#### Copilot Parameters (3 tests) +- `test_copilot_params_custom_mcp_no_mcp_flag` - Verifies custom MCPs don't generate --mcp flags +- `test_copilot_params_builtin_mcp_no_mcp_flag` - Verifies built-in MCPs don't generate --mcp flags (all MCPs handled via firewall) +- `test_generate_copilot_params_custom_mcp_skipped` - Verifies custom MCPs are skipped #### Markdown Parsing (4 tests) - `test_parse_markdown_valid` - Tests valid markdown with front matter @@ -78,7 +78,7 @@ The test suite provides comprehensive coverage for: - Schedule generation (hourly/daily, deterministic) - Repository configuration generation - Checkout step generation -- Agency parameter generation +- Copilot parameter generation ✅ **Error Handling** - Missing front matter diff --git a/tests/VERIFICATION.md b/tests/VERIFICATION.md index 56e73a0..6b8473b 100644 --- a/tests/VERIFICATION.md +++ b/tests/VERIFICATION.md @@ -136,7 +136,7 @@ fn test_compile_pipeline_basic() { - `test_generate_schedule_*` - verifies schedule generation - `test_generate_repositories_*` - verifies repository YAML generation - `test_generate_checkout_steps_*` - verifies checkout step generation - - `test_generate_agency_params_*` - verifies parameter generation + - `test_generate_copilot_params_*` - verifies parameter generation - `test_parse_markdown_*` - verifies markdown parsing including error cases - Template and structure validation tests diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 4896249..713e976 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -104,8 +104,8 @@ fn test_compiled_yaml_structure() { "Template should contain agent_name marker" ); assert!( - template_content.contains("{{ agency_params }}"), - "Template should contain agency_params marker" + template_content.contains("{{ copilot_params }}"), + "Template should contain copilot_params marker" ); assert!( template_content.contains("{{ compiler_version }}"), @@ -1707,3 +1707,309 @@ fn test_compile_auto_discover_skips_missing_source() { let _ = fs::remove_dir_all(&temp_dir); } + +/// Test that submit-pr-review fails compilation when allowed-events is missing +#[test] +fn test_submit_pr_review_requires_allowed_events() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-spr-events-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let test_input = temp_dir.join("spr-agent.md"); + let test_content = r#"--- +name: "PR Review Agent" +description: "Agent that submits PR reviews but has no allowed-events" +permissions: + write: my-write-sc +safe-outputs: + submit-pr-review: + allowed-repositories: + - self +--- + +## PR Review Agent + +Submit PR reviews. +"#; + fs::write(&test_input, test_content).expect("Failed to write test input"); + + let output_path = temp_dir.join("spr-agent.yml"); + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + + assert!( + !output.status.success(), + "Compiler should fail when submit-pr-review lacks allowed-events" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("allowed-events"), + "Error message should mention allowed-events: {stderr}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +/// Test that submit-pr-review fails compilation when allowed-events is an empty list +#[test] +fn test_submit_pr_review_requires_nonempty_allowed_events() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-spr-empty-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let test_input = temp_dir.join("spr-agent.md"); + let test_content = r#"--- +name: "PR Review Agent" +description: "Agent that submits PR reviews but has empty allowed-events" +permissions: + write: my-write-sc +safe-outputs: + submit-pr-review: + allowed-events: [] +--- + +## PR Review Agent + +Submit PR reviews. +"#; + fs::write(&test_input, test_content).expect("Failed to write test input"); + + let output_path = temp_dir.join("spr-agent.yml"); + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + + assert!( + !output.status.success(), + "Compiler should fail when submit-pr-review has empty allowed-events" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("allowed-events"), + "Error message should mention allowed-events: {stderr}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +/// Test that submit-pr-review compiles successfully with proper config +#[test] +fn test_submit_pr_review_compiles_with_allowed_events() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-spr-pass-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let test_input = temp_dir.join("spr-agent.md"); + let test_content = r#"--- +name: "PR Review Agent" +description: "Agent that submits PR reviews with proper config" +permissions: + write: my-write-sc +safe-outputs: + submit-pr-review: + allowed-events: + - comment + - approve-with-suggestions +--- + +## PR Review Agent + +Submit PR reviews. +"#; + fs::write(&test_input, test_content).expect("Failed to write test input"); + + let output_path = temp_dir.join("spr-agent.yml"); + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + + assert!( + output.status.success(), + "Compiler should succeed with proper submit-pr-review config: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +/// Test that update-pr fails compilation when vote is reachable but allowed-votes is missing +#[test] +fn test_update_pr_requires_allowed_votes_when_vote_reachable() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-uprvote-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let test_input = temp_dir.join("upr-agent.md"); + // No allowed-operations → vote is reachable; no allowed-votes → should fail + let test_content = r#"--- +name: "Update PR Agent" +description: "Agent that votes on PRs but forgot to set allowed-votes" +permissions: + write: my-write-sc +safe-outputs: + update-pr: + allowed-repositories: + - self +--- + +## Update PR Agent + +Vote on pull requests. +"#; + fs::write(&test_input, test_content).expect("Failed to write test input"); + + let output_path = temp_dir.join("upr-agent.yml"); + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + + assert!( + !output.status.success(), + "Compiler should fail when update-pr lacks allowed-votes with vote reachable" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("allowed-votes"), + "Error message should mention allowed-votes: {stderr}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +/// Test that update-pr compiles successfully when vote is restricted via allowed-operations +#[test] +fn test_update_pr_compiles_when_vote_excluded_from_allowed_operations() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-uprnotvote-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let test_input = temp_dir.join("upr-agent.md"); + let test_content = r#"--- +name: "Update PR Agent" +description: "Agent that sets reviewers but cannot vote" +permissions: + write: my-write-sc +safe-outputs: + update-pr: + allowed-operations: + - add-reviewers + - set-auto-complete +--- + +## Update PR Agent + +Manage pull requests. +"#; + fs::write(&test_input, test_content).expect("Failed to write test input"); + + let output_path = temp_dir.join("upr-agent.yml"); + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + + assert!( + output.status.success(), + "Compiler should succeed when vote is excluded from allowed-operations: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +/// Test that update-pr compiles successfully when allowed-votes is set +#[test] +fn test_update_pr_compiles_when_allowed_votes_set() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-uprvoteset-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let test_input = temp_dir.join("upr-agent.md"); + let test_content = r#"--- +name: "Update PR Agent" +description: "Agent that can vote on PRs with proper config" +permissions: + write: my-write-sc +safe-outputs: + update-pr: + allowed-votes: + - approve-with-suggestions + - wait-for-author +--- + +## Update PR Agent + +Vote on pull requests. +"#; + fs::write(&test_input, test_content).expect("Failed to write test input"); + + let output_path = temp_dir.join("upr-agent.yml"); + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + + assert!( + output.status.success(), + "Compiler should succeed with proper update-pr vote config: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let _ = fs::remove_dir_all(&temp_dir); +}