// @ts-check /// const fs = require("fs"); const os = require("os"); const path = require("path"); const crypto = require("crypto"); const { normalizeBranchName } = require("./normalize_branch_name.cjs"); const { estimateTokens } = require("./estimate_tokens.cjs"); const { writeLargeContentToFile } = require("./write_large_content_to_file.cjs"); const { getCurrentBranch } = require("./get_current_branch.cjs"); const { getBaseBranch } = require("./get_base_branch.cjs"); const { lookupCheckout } = require("./checkout_manifest.cjs"); const { generateGitPatch } = require("./generate_git_patch.cjs"); const { getExcludedFiles, WORKFLOW_COMPILATION_NOTICE } = require("./workflow_file_exclusions.cjs"); const { generateGitBundle } = require("./generate_git_bundle.cjs"); const { hasMergeCommitsInRange, execGitSync, ensureSafeDirectoryTrust } = require("./git_helpers.cjs"); const { enforceCommentLimits, MAX_COMMENT_INPUT_LENGTH } = require("./comment_limit_helpers.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_CONFIG, ERR_PARSE, ERR_SYSTEM, ERR_VALIDATION } = require("./error_codes.cjs"); const { findRepoCheckout } = require("./find_repo_checkout.cjs"); const { resolveTargetRepoConfig, resolveAndValidateRepo, isSideRepoTarget, patchWorkspaceTargetsRepo } = require("./repo_helpers.cjs"); const { generateTemporaryId, getOrGenerateTemporaryId } = require("./temporary_id.cjs"); const { parseAllowedExtensionsEnv } = require("./allowed_extensions_helpers.cjs"); const { buildExcludePathspecs, getStagedPatchDiffSizeBytes } = require("./git_patch_utils.cjs"); const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs"); const { parseDeduplicateByTitle, normalizeTitleForDedup, findDuplicateByTitle } = require("./issue_title_dedup.cjs"); const { isProbingNoopMessage, validateCreatePullRequestIntent, validatePushToPullRequestBranchIntent, validateCreateIssueIntent, validateAddCommentIntent } = require("./intent_probe.cjs"); const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); const { lstatGuard } = require("./symlink_guard.cjs"); const { validateValueAgainstSchema } = require("./schema-validation.cjs"); const { resolveDataSchema } = require("./data_schema_normalizer.cjs"); const { normalizeBlankOptionalFields } = require("./optional_field_normalizer.cjs"); const { clearValidationMarker, formatJSONFiles, runCustomMemoryValidation, writeValidationMarker } = require("./memory_custom_validation.cjs"); const { compileFileGlobPatterns, isMemoryFileEligible } = require("./memory_file_eligibility.cjs"); const { normalizeRuntimeMessage, readClaimScopeContext, withClaimExecution, currentClaimHandle, claimArtifactPath } = require("./work_queue_claim_scope.cjs"); function createEligibleMemoryValidationView(memoryDir, isEligibleFile) { let validationDir; try { validationDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-repo-memory-validation-")); } catch (error) { throw new Error(`${ERR_SYSTEM}: Failed to create repo-memory validation directory: ${getErrorMessage(error)}`, { cause: error }); } /** * @param {string} targetPath */ function removeValidationDirectory(targetPath) { try { fs.rmSync(targetPath, { recursive: true, force: true }); } catch (error) { throw new Error(`${ERR_SYSTEM}: Failed to remove repo-memory validation directory: ${getErrorMessage(error)}`, { cause: error }); } } try { /** * @param {string} sourceDir * @param {string} relativePath */ function copyEligibleFiles(sourceDir, relativePath) { for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = path.join(sourceDir, entry.name); const relativeFilePath = relativePath ? path.join(relativePath, entry.name) : entry.name; if (entry.isDirectory()) { if (entry.name !== ".git") { copyEligibleFiles(sourcePath, relativeFilePath); } } else if (entry.isFile() && isEligibleFile(relativeFilePath)) { const targetPath = path.join(validationDir, relativeFilePath); fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.copyFileSync(sourcePath, targetPath); } } } copyEligibleFiles(memoryDir, ""); return validationDir; } catch (error) { try { removeValidationDirectory(validationDir); } catch (cleanupError) { throw new Error(`${ERR_SYSTEM}: Failed to create repo-memory validation view: ${getErrorMessage(error)}; cleanup also failed: ${getErrorMessage(cleanupError)}`, { cause: error }); } throw error; } } /** PR event names used for target:triggering context validation across all safe-output handlers. */ const PR_EVENT_NAMES = new Set(["pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment"]); /** * Resolve effective event name and payload from an invocation context, * falling back to the raw GitHub Actions context. * @param {ReturnType | null | undefined} invocationContext * @param {any} rawContext */ function resolveEffectiveContext(invocationContext, rawContext) { return { effectiveEventName: invocationContext?.eventName || rawContext.eventName, effectivePayload: invocationContext?.eventPayload || rawContext.payload, }; } function resolvePRHeadBaselineForPush(branchName, repoSlug, prNumber, server) { const baselineBranch = (process.env.GH_AW_PR_HEAD_BASE_BRANCH || "").trim(); const baselineRepo = (process.env.GH_AW_PR_HEAD_BASE_REPO || "").trim(); const baselineRef = (process.env.GH_AW_PR_HEAD_BASE_REF || "").trim(); const baselineSha = (process.env.GH_AW_PR_HEAD_BASE_SHA || "").trim(); const baselinePRNumber = (process.env.GH_AW_PR_HEAD_BASE_PR_NUMBER || "").trim(); const headRepo = (process.env.GH_AW_PR_HEAD_REPO || "").trim(); if (!baselineBranch || baselineBranch !== branchName || (!baselineRef && !baselineSha)) { return null; } if (baselineRepo && baselineRepo.toLowerCase() !== repoSlug.toLowerCase()) { server.debug(`Ignoring PR-head baseline for ${baselineBranch}: recorded repo ${baselineRepo} does not match target repo ${repoSlug}`); return null; } // Branch name + repo alone don't uniquely identify a fork PR: two forks can open // identically-named branches against the same base repo. In wildcard/batch workflows // where the target PR differs from the triggering PR, require the recorded PR number // to match the effective target PR before reusing the baseline. const effectivePRNumber = prNumber != null ? String(prNumber).trim() : ""; if (!baselinePRNumber || !effectivePRNumber || effectivePRNumber !== baselinePRNumber) { server.debug(`Ignoring PR-head baseline for ${baselineBranch}: recorded PR #${baselinePRNumber || "unknown"} does not match target PR #${effectivePRNumber || "unknown"}`); return null; } server.debug(`Using recorded PR-head baseline for incremental push patch: ${baselineRef || baselineSha}`); return { ref: baselineRef, sha: baselineSha, headRepo, }; } /** * Read and parse a JSON file. * @param {string} filePath * @returns {any} */ function readJSONFile(filePath) { let parsed; try { parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch (err) { throw new Error(`${ERR_PARSE}: ` + "Failed to parse JSON file " + filePath + ": " + getErrorMessage(err), { cause: err }); } return parsed; } const safeOutputsTools = readJSONFile(path.join(__dirname, "safe_outputs_tools.json")); const safeOutputsToolMap = new Map(safeOutputsTools.map(tool => [tool.name.replace(/-/g, "_"), tool])); const { validateOperation } = require("./ledger_builtin.cjs"); /** * @param {string} error * @returns {{content: Array<{type: "text", text: string}>, isError: true}} */ function buildIntentErrorResponse(error) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error, }), }, ], isError: true, }; } /** * Build an actionable missing temporary_id error for configured tools. * @param {string} toolName * @param {string} configKey * @returns {string} */ function buildMissingTemporaryIdError(toolName, configKey) { const temporaryIdExamples = { create_pull_request: "aw_pr1", create_issue: "aw_issue1", }; const example = temporaryIdExamples[toolName] || "aw_item1"; return `${toolName} requires 'temporary_id' when safe-outputs.${configKey}.require-temporary-id is enabled. Set temporary_id (for example "${example}") and retry.`; } /** * @param {Record} safeOutputsConfig * @param {string} toolName * @returns {Record} */ function getSafeOutputsToolConfig(safeOutputsConfig, toolName) { return safeOutputsConfig?.[toolName] || safeOutputsConfig?.[toolName.replace(/_/g, "-")] || {}; } /** * @param {Record} entry * @param {string[]} fieldNames * @returns {boolean} */ function hasExplicitTargetParameter(entry, fieldNames) { return fieldNames.some(field => entry[field] !== undefined && entry[field] !== null && String(entry[field]).trim() !== ""); } /** * @param {string} toolName * @returns {{primary?: string, anyOf?: string[], allOf?: string[]} | null} */ function getWildcardTargetRequirement(toolName) { return safeOutputsToolMap.get(toolName)?.["x-safe-outputs-target-requirements"]?.["*"] || null; } /** * Returns true if `args` contains at least one meaningful field for update_pull_request: * a string `title`, a string `body`, or `update_branch === true`. * Mirrors the downstream requiresOneOf:title,body,update_branch validation in * safe_output_type_validator.cjs (which also excludes field === false from the count). * @param {Record | null | undefined} args * @returns {boolean} */ function hasUpdatePullRequestFields(args) { const safeArgs = args || {}; return typeof safeArgs.title === "string" || typeof safeArgs.body === "string" || safeArgs.update_branch === true; } /** * Split a combined title/body message when an agent incorrectly sends both values * in `title` separated by newlines. * * Supported examples: * - "My title\n\nMy body" * - "Title: My title\nBody: My body" * * @param {Record | null | undefined} args * @returns {Record} */ function normalizeCombinedTitleBodyArgs(args) { const safeArgs = { ...(args || {}) }; if (typeof safeArgs.title !== "string") return safeArgs; // An explicitly supplied `body` (including an empty string) is a caller-provided // value and must never be overwritten by this recovery normalization. if (typeof safeArgs.body === "string") return safeArgs; const rawTitle = safeArgs.title.replace(/\r\n/g, "\n").trim(); if (!rawTitle.includes("\n")) return safeArgs; const lines = rawTitle.split("\n"); const firstLineIndex = lines.findIndex(line => line.trim().length > 0); if (firstLineIndex < 0) return safeArgs; const firstLine = lines[firstLineIndex]; const remainingLines = lines.slice(firstLineIndex + 1); while (remainingLines.length > 0 && remainingLines[0].trim().length === 0) { remainingLines.shift(); } if (remainingLines.length === 0) return safeArgs; // Only treat this as the labeled form (and strip the "Title:"/"Body:" prefixes) // when the first line actually starts with a "Title:" label. Otherwise a plain // split may have a body that legitimately starts with the literal text "Body:". const titleLabelMatch = firstLine.match(/^title\s*:\s*/i); const normalizedTitle = titleLabelMatch ? firstLine.slice(titleLabelMatch[0].length).trim() : firstLine.trim(); if (!normalizedTitle) return safeArgs; const remainder = remainingLines.join("\n"); const normalizedBody = titleLabelMatch ? remainder.replace(/^body\s*:\s*/i, "").trim() : remainder.trim(); if (!normalizedBody) return safeArgs; safeArgs.title = normalizedTitle; safeArgs.body = normalizedBody; return safeArgs; } /** * Parse branch pattern configuration from array or comma-separated string. * @param {string[]|string|undefined} value * @returns {string[]} */ function parseAllowedBranchPatterns(value) { if (Array.isArray(value)) { return value.map(item => String(item).trim()).filter(Boolean); } if (typeof value === "string") { return value .split(",") .map(item => item.trim()) .filter(Boolean); } return []; } /** * Parse trusted comment IDs supplied by workflow configuration. * @param {unknown} value * @returns {Set} */ function parseAllowedCommentIds(value) { const values = []; const visit = item => { if (Array.isArray(item)) { for (const child of item) visit(child); return; } if (typeof item === "number" && Number.isInteger(item) && item > 0) { values.push(String(item)); return; } if (typeof item !== "string") { return; } const trimmed = item.trim(); if (!trimmed) { return; } if (trimmed.startsWith("[") || trimmed.startsWith('"')) { try { visit(JSON.parse(trimmed)); return; } catch { // Fall through to delimiter parsing. } } for (const part of trimmed.split(/[,\s]+/)) { if (/^[1-9]\d*$/.test(part)) { values.push(part); } } }; visit(value); return new Set(values); } /** * Validate an agent-supplied add_comment.comment_id against the trusted workflow allowlist. * Does not mutate the supplied entry; the caller is responsible for applying the normalized value. * @param {Record} entry * @param {Record} addCommentConfig * @returns {{error: {content: Array<{type: "text", text: string}>, isError: true}} | {error: null, commentId: number | undefined}} */ function validateAllowedAddCommentId(entry, addCommentConfig) { if (entry.comment_id === undefined || entry.comment_id === null || String(entry.comment_id).trim() === "") { return { error: null, commentId: undefined }; } if (addCommentConfig.target !== "*") { return { error: buildIntentErrorResponse("add_comment comment_id is only allowed when safe-outputs.add-comment.target is '*' and the ID is listed in safe-outputs.add-comment.allows-comment-ids.") }; } const commentId = Number(entry.comment_id); if (!Number.isInteger(commentId) || commentId <= 0) { return { error: buildIntentErrorResponse("add_comment comment_id must be a positive integer.") }; } const allowedCommentIds = parseAllowedCommentIds(addCommentConfig.allows_comment_ids ?? addCommentConfig["allows-comment-ids"]); if (allowedCommentIds.size === 0) { return { error: buildIntentErrorResponse("add_comment comment_id requires safe-outputs.add-comment.allows-comment-ids to list trusted comment IDs.") }; } if (!allowedCommentIds.has(String(commentId))) { return { error: buildIntentErrorResponse("add_comment comment_id is not listed in safe-outputs.add-comment.allows-comment-ids.") }; } return { error: null, commentId }; } /** * @param {string} branch * @param {string[]} allowedPatterns * @returns {boolean} */ function isAllowedBranch(branch, allowedPatterns) { for (const pattern of allowedPatterns) { if (branch === pattern) { return true; } if (pattern === "*") { // Add this fast-path return true; } if (pattern.includes("*") && globPatternToRegex(pattern, { pathMode: true, caseSensitive: true }).test(branch)) { return true; } } return false; } /** * Resolve and validate a workspace-relative patch path. * @param {string|undefined} workspacePath * @returns {{success: true, absolutePath: string} | {success: false, error: string}} */ function resolvePatchWorkspacePath(workspacePath) { const candidatePath = typeof workspacePath === "string" ? workspacePath.trim() : ""; if (!candidatePath) { return { success: false, error: "patch_workspace_path is empty" }; } const workspaceRoot = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd()); const resolved = path.resolve(workspaceRoot, candidatePath); const relative = path.relative(workspaceRoot, resolved); if (relative.startsWith("..") || path.isAbsolute(relative)) { return { success: false, error: `Invalid patch_workspace_path '${candidatePath}': path must stay under GITHUB_WORKSPACE` }; } if (!fs.existsSync(resolved)) { return { success: false, error: `Invalid patch_workspace_path '${candidatePath}': directory does not exist` }; } let resolvedStats; try { resolvedStats = fs.statSync(resolved); } catch (err) { return { success: false, error: `Failed to inspect patch_workspace_path '${candidatePath}': ${getErrorMessage(err)}` }; } if (!resolvedStats.isDirectory()) { return { success: false, error: `Invalid patch_workspace_path '${candidatePath}': path is not a directory` }; } return { success: true, absolutePath: resolved }; } /** * Create handlers for safe output tools * @param {Object} server - The MCP server instance for logging * @param {Function} appendSafeOutput - Function to append entries to the output file * @param {Object} [config] - Optional configuration object with safe output settings * @returns {Object} An object containing all handler functions */ function createHandlers(server, appendSafeOutput, config = {}) { // Ensure the workspace is trusted for the lifetime of this server process. // This covers all current and future handlers automatically; per-handler calls // additionally cover per-request checkout paths that differ from GITHUB_WORKSPACE. ensureSafeDirectoryTrust(process.env.GITHUB_WORKSPACE || process.cwd(), server); const TOKEN_THRESHOLD = 16000; /** * Session-scoped per-type operation counters. * Incremented on every successful appendSafeOutput call (MCE4 dual enforcement). * @type {Map} */ const operationCounts = new Map(); const uploadedAssetPaths = new Set(); const scopedKey = value => JSON.stringify([currentClaimHandle() || null, value]); /** * Return the explicitly user-configured max for a safe-output type, or null if not set / unlimited. * Uses getSafeOutputsToolConfig for consistent key-normalisation (hyphens → underscores). * Does NOT fall back to validation-config defaults: MCP-time enforcement is only * applied when the user has explicitly set a limit; downstream enforcement covers defaults. * Per Safe Outputs Specification MCE5: the same config source as the processor. * @param {string} type - normalised safe-output type name (e.g. "add_comment") * @returns {number | null} */ function getExplicitMax(type) { const toolConfig = getSafeOutputsToolConfig(config, type); if (!toolConfig || typeof toolConfig !== "object") return null; if (!("max" in toolConfig)) return null; const maxVal = toolConfig.max; if (maxVal === -1) return null; // -1 means unlimited if (typeof maxVal === "number" && Number.isInteger(maxVal) && maxVal > 0) { return maxVal; } return null; } /** * Enforce the per-type operation count limit at invocation time. * Throws a JSON-RPC -32602 error when the configured max has already been reached. * Per Safe Outputs Specification MCE4: Dual Enforcement — constraints MUST be * enforced at both invocation time (MCP server) and processing time (safe output * processor) to provide defence-in-depth. * @param {string} type - normalised safe-output type name */ function enforcePerTypeMax(type) { const maxAllowed = getExplicitMax(type); if (maxAllowed === null) return; // no explicit limit configured const current = operationCounts.get(scopedKey(type)) || 0; if (current >= maxAllowed) { throw { code: -32602, message: `E002: ${type} limit reached — ${current} of ${maxAllowed} already used this run`, data: { constraint: "max", type, limit: maxAllowed, guidance: `You have used all ${maxAllowed} ${type} operations for this run. ` + `Further ${type} calls will be ignored. Prioritize the most important items ` + `(e.g. consolidate multiple updates into one), or call noop. ` + `Note: other safe-output types have independent budgets, so applying one type ` + `without its companion type can leave inconsistent state.`, }, }; } } /** * Append a safe-output entry after enforcing the per-type max count. * Increments the session counter only after a successful write, mirroring the * approach used by inlineReviewCommentCount so that write errors do not advance * the counter. * Per Safe Outputs Specification MCE4: invocation-time half of dual enforcement. * @param {Record} entry */ /** * Probing entries are schema probes (e.g. `noop` with message "test") rather than a * genuine signal. They are ignored entirely: not recorded, not counted against the * type budget, so a probe never crowds out or short-circuits the real signal. * @param {Record} entry * @returns {boolean} */ const isIgnoredProbingEntry = entry => entry?.type === "noop" && isProbingNoopMessage(entry?.message); const appendSafeOutputCounted = entry => { if (isIgnoredProbingEntry(entry)) { server.debug(`Ignoring probing noop call (not recorded, does not consume the noop budget): ${JSON.stringify(entry?.message)}`); return; } const type = entry?.type; if (type) enforcePerTypeMax(type); appendSafeOutput(entry); if (type) operationCounts.set(scopedKey(type), (operationCounts.get(scopedKey(type)) || 0) + 1); }; /** * Validate schema-declared explicit target parameters for wildcard-target tools. * @param {Record} entry * @returns {{content: Array<{type: "text", text: string}>, isError: true} | null} */ const validateWildcardTargetRequirement = entry => { const toolName = entry?.type; const requirement = getWildcardTargetRequirement(toolName); if (!requirement) { return null; } const toolConfig = getSafeOutputsToolConfig(config, toolName); if (toolConfig.target !== "*") { return null; } const configKey = toolName.replace(/_/g, "-"); const anyOf = Array.isArray(requirement.anyOf) ? requirement.anyOf : []; if (anyOf.length > 0 && !hasExplicitTargetParameter(entry, anyOf)) { const primary = requirement.primary || anyOf[0]; const guidance = anyOf.length === 1 ? primary : `one of: ${anyOf.join(", ")}`; return buildIntentErrorResponse(`${toolName} requires ${primary} when safe-outputs.${configKey}.target is '*'. Provide ${guidance} and retry.`); } const allOf = Array.isArray(requirement.allOf) ? requirement.allOf : []; for (const field of allOf) { if (!hasExplicitTargetParameter(entry, [field])) { return buildIntentErrorResponse(`${toolName} requires ${field} when safe-outputs.${configKey}.target is '*'. Provide ${field} and retry.`); } } return null; }; /** * Detect and offload large string fields to files. * @param {Record} entry * @returns {Object | null} MCP response if large content was handled, else null */ const maybeHandleLargeContent = entry => { /** @type {any} */ let largeContent = null; /** @type {any} */ let largeFieldName = null; for (const [key, value] of Object.entries(entry)) { if (typeof value === "string") { const tokens = estimateTokens(value); if (tokens > TOKEN_THRESHOLD) { largeContent = value; largeFieldName = key; server.debug(`Field '${key}' has ${tokens} tokens (exceeds ${TOKEN_THRESHOLD})`); break; } } } if (!largeContent || !largeFieldName) { return null; } const fileInfo = writeLargeContentToFile(largeContent); entry[largeFieldName] = `[Content too large, saved to file: ${fileInfo.filename}]`; appendSafeOutputCounted(entry); return { content: [ { type: "text", text: JSON.stringify(fileInfo), }, ], }; }; /** * Default handler for safe output tools * Spec cross-reference: Safe Output Outcome Evaluation §2/§4/§5/§6/§7/§8/§9/§10/§11/§12/§13/§14/§15/§16/§18/§19/§20/§21/§22/§23/§24/§25/§26/§27/§28/§29. * @param {string} type - The tool type * @returns {Function} Handler function */ const defaultHandler = type => args => { const entry = { ...(args || {}), type }; if (entry.data !== undefined) { const toolConfig = getSafeOutputsToolConfig(config, type); const dataEnabled = toolConfig?.data_enabled === true || (toolConfig?.data_schema && typeof toolConfig.data_schema === "object"); if (!dataEnabled) { return buildIntentErrorResponse(`${type} data is not enabled (set safe-outputs.data in workflow frontmatter)`); } /** @type {Record|null} */ let dataSchema = null; try { if (toolConfig?.data_schema !== undefined) { dataSchema = resolveDataSchema(toolConfig.data_schema, `safe-outputs.${type}.data`); } } catch (error) { return buildIntentErrorResponse(`${type} data schema is invalid: ${getErrorMessage(error)}`); } if (dataSchema) { const dataSchemaError = validateValueAgainstSchema(entry.data, dataSchema); if (dataSchemaError) { const errorPath = dataSchemaError.path ? `.${dataSchemaError.path}` : ""; return buildIntentErrorResponse(`${type} data${errorPath} ${dataSchemaError.message}`); } } } const wildcardTargetValidationError = validateWildcardTargetRequirement(entry); if (wildcardTargetValidationError) { return wildcardTargetValidationError; } const largeContentResponse = maybeHandleLargeContent(entry); if (largeContentResponse) return largeContentResponse; // Normal case - no large content appendSafeOutputCounted(entry); return { content: [ { type: "text", text: JSON.stringify({ result: "success" }), }, ], }; }; const ledgerBuiltinHandler = (ledgerType, operation) => args => { const ledgers = config.ledger_append?.ledgers || []; const candidates = ledgers.filter(ledger => ledger.type === ledgerType); const target = args?.ledger === undefined && candidates.length === 1 ? candidates[0] : candidates.find(ledger => ledger.name === args?.ledger); if (!target) return buildIntentErrorResponse("Specify a configured ledger of the correct built-in type."); const { ledger: _ledger, temp_id, ...fields } = args || {}; const claimHandle = currentClaimHandle(); if (claimHandle) { for (const field of ["claim_handle", "claim_id", "work_id"]) delete fields[field]; } if (Object.hasOwn(fields, "operation")) return buildIntentErrorResponse("Invalid built-in ledger operation arguments."); const record = { operation, ...fields }; try { validateOperation(record, target); } catch { return buildIntentErrorResponse("Invalid built-in ledger operation arguments."); } return defaultHandler("ledger_append")({ ledger: target.name, ...(temp_id !== undefined && { temp_id }), ...(claimHandle ? { claim_handle: claimHandle } : {}), ...record }); }; const ledgerAgentAppendHandler = args => { const ledgers = config.ledger_append?.ledgers || []; const target = ledgers.find(ledger => ledger.name === args?.ledger) || (args?.ledger === undefined && ledgers.length === 1 ? ledgers[0] : null); if (target?.type === "notes") return buildIntentErrorResponse("Use ledger_note_add or ledger_note_vote for notes ledgers."); if (!target) return buildIntentErrorResponse("Specify a configured ledger."); if (!target.type) { if (!args?.record || typeof args.record !== "object" || Array.isArray(args.record) || args.operation !== undefined) { return buildIntentErrorResponse("Custom ledger requires a record object. Use { ledger, record: { ... } }, not operation or value."); } if (target.schema) { const schemaError = validateValueAgainstSchema(args.record, target.schema); if (schemaError) { const errorPath = schemaError.path ? `.${schemaError.path}` : ""; return buildIntentErrorResponse(`Custom ledger record${errorPath} ${schemaError.message}`); } } } return defaultHandler("ledger_append")(args); }; const createIssueConfig = config.create_issue || {}; let deduplicateByTitle = { enabled: false, maxDistance: 0 }; try { deduplicateByTitle = parseDeduplicateByTitle(createIssueConfig.deduplicate_by_title); } catch (error) { throw new Error(`${ERR_VALIDATION}: ${getErrorMessage(error)}`, { cause: error }); } const createIssueTitlePrefix = createIssueConfig.title_prefix ?? ""; /** @type {Map>} */ const seenIssueTitlesByRepo = new Map(); /** * Handler for upload_asset tool * Spec cross-reference: not part of the numbered outcome types in Safe Output Outcome Evaluation v1.0.0. */ const uploadAssetHandler = args => { const branchName = process.env.GH_AW_ASSETS_BRANCH; if (!branchName) throw new Error(`${ERR_CONFIG}: GH_AW_ASSETS_BRANCH not set`); // Normalize the branch name to ensure it's a valid git branch name const normalizedBranchName = normalizeBranchName(branchName); const { path: filePath } = args; // Validate file path is within allowed directories const absolutePath = path.resolve(filePath); const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd(); const tmpDir = "/tmp"; const isInWorkspace = absolutePath.startsWith(path.resolve(workspaceDir)); const isInTmp = absolutePath.startsWith(tmpDir); if (!isInWorkspace && !isInTmp) { throw new Error(`${ERR_CONFIG}: File path must be within workspace directory (${workspaceDir}) or /tmp directory. ` + `Provided path: ${filePath} (resolved to: ${absolutePath})`); } if (uploadedAssetPaths.has(scopedKey(absolutePath))) { throw new Error(`${ERR_VALIDATION}: Duplicate upload_asset source path is not allowed: ${filePath}`); } // Validate file exists if (!fs.existsSync(filePath)) { throw new Error(`${ERR_SYSTEM}: File not found: ${filePath}`); } // Get file stats let stats; try { stats = fs.statSync(filePath); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to inspect file ${filePath}: ${getErrorMessage(err)}`, { cause: err }); } const sizeBytes = stats.size; const sizeKB = Math.ceil(sizeBytes / 1024); // Check file size - read from environment variable if available const maxSizeKB = process.env.GH_AW_ASSETS_MAX_SIZE_KB ? parseInt(process.env.GH_AW_ASSETS_MAX_SIZE_KB, 10) : 10240; // Default 10MB if (sizeKB > maxSizeKB) { throw new Error(`${ERR_VALIDATION}: File size ${sizeKB} KB exceeds maximum allowed size ${maxSizeKB} KB`); } // Check file extension - read from environment variable if available const ext = path.extname(filePath).toLowerCase(); const parsedAllowedExts = parseAllowedExtensionsEnv(process.env.GH_AW_ASSETS_ALLOWED_EXTS); if (parsedAllowedExts?.hasUnresolvedExpression) { throw new Error(`${ERR_CONFIG}: GH_AW_ASSETS_ALLOWED_EXTS contains unresolved GitHub Actions expression. Ensure expressions resolve before safe outputs validation.`); } const allowedExts = parsedAllowedExts ? parsedAllowedExts.normalizedValues : [ // Default set as specified in problem statement ".png", ".jpg", ".jpeg", ]; if (!allowedExts.includes(ext)) { throw new Error(`${ERR_VALIDATION}: File extension '${ext}' is not allowed. Allowed extensions: ${allowedExts.join(", ")}`); } // Create assets directory // Use RUNNER_TEMP so the staged files land on the host filesystem (shared with // the artifact-upload step), matching the same pattern used by upload_artifact. const assetsRoot = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "assets"); const assetsDir = currentClaimHandle() ? claimArtifactPath(assetsRoot, currentClaimHandle()) : assetsRoot; if (!fs.existsSync(assetsDir)) { try { fs.mkdirSync(assetsDir, { recursive: true }); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to create directory ${assetsDir}: ${getErrorMessage(err)}`, { cause: err }); } } // Read file and compute hash let fileContent; try { fileContent = fs.readFileSync(filePath); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to read file ${filePath}: ${getErrorMessage(err)}`, { cause: err }); } const sha = crypto.createHash("sha256").update(fileContent).digest("hex"); // Extract filename and extension const fileName = path.basename(filePath); const fileExt = path.extname(fileName).toLowerCase(); // Key the staged file by its declared source path so same-basename assets // cannot overwrite each other before the privileged publishing job. const stagedFileName = `${crypto.createHash("sha256").update(filePath).digest("hex")}${fileExt}`; const targetPath = path.join(assetsDir, stagedFileName); try { fs.copyFileSync(filePath, targetPath); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to copy file ${filePath} to ${targetPath}: ${getErrorMessage(err)}`, { cause: err }); } // Generate target filename as sha + extension (lowercased) const targetFileName = currentClaimHandle() ? `claims/${path.basename(claimArtifactPath("", currentClaimHandle()))}/${sha}${fileExt}` : (sha + fileExt).toLowerCase(); const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; const repo = process.env.GITHUB_REPOSITORY || "owner/repo"; const assetBranch = currentClaimHandle() ? `${normalizedBranchName}/claims/${path.basename(claimArtifactPath("", currentClaimHandle()))}` : normalizedBranchName; let url; try { const serverHostname = new URL(githubServer).hostname; if (serverHostname === "github.com") { url = `https://github.com/${repo}/blob/${assetBranch}/${targetFileName}?raw=true`; } else { // GitHub Enterprise Server - raw content is served from the same host with /raw/ path url = `${githubServer}/${repo}/raw/${assetBranch}/${targetFileName}`; } } catch { url = `${githubServer}/${repo}/raw/${assetBranch}/${targetFileName}`; } // Create entry for safe outputs const entry = { type: "upload_asset", path: filePath, fileName: fileName, sha: sha, size: sizeBytes, url: url, targetFileName: targetFileName, }; appendSafeOutputCounted(entry); uploadedAssetPaths.add(scopedKey(absolutePath)); return { content: [ { type: "text", text: JSON.stringify({ result: url }), }, ], }; }; /** * Handler for create_pull_request tool * Spec cross-reference: Safe Output Outcome Evaluation §1 (`create_pull_request`). * Resolves the current branch if branch is not provided or is the base branch * Validates exploratory probe payloads against the resolved effective branch * Generates git patch for the changes (unless allow-empty is true) * Supports multi-repo scenarios via the optional 'repo' parameter */ const createPullRequestHandler = async args => { /** @type {any} */ const normalizedArgs = normalizeCombinedTitleBodyArgs(args); /** @type {any} */ const entry = { ...normalizedArgs, type: "create_pull_request" }; if (config.create_pull_request?.require_temporary_id === true && !entry.temporary_id) { return buildIntentErrorResponse(buildMissingTemporaryIdError("create_pull_request", "create-pull-request")); } // Resolve target repo configuration and validate the target repo early // This is needed before getBaseBranch to ensure we resolve the base branch // for the correct repository (especially in cross-repo scenarios) const prConfig = config.create_pull_request || {}; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(prConfig); // Resolve and validate the target repository from the entry const repoResult = resolveAndValidateRepo(entry, defaultTargetRepo, allowedRepos, "pull request"); if (!repoResult.success) { let error = repoResult.error; const owningRepo = process.env.GITHUB_REPOSITORY; if (entry.repo === owningRepo && defaultTargetRepo && defaultTargetRepo !== owningRepo) { error += ` Hint: This workflow runs in '${owningRepo}' but is configured to target '${defaultTargetRepo}'. Omit the 'repo' parameter to use the configured target, or pass repo: '${defaultTargetRepo}'.`; } return { content: [ { type: "text", text: JSON.stringify({ result: "error", error, }), }, ], isError: true, }; } const { repoParts } = repoResult; const configuredHeadRepo = typeof prConfig["head-repo"] === "string" ? prConfig["head-repo"].trim() : ""; entry.head_repo = configuredHeadRepo || repoResult.repo; // Determine the working directory for git operations // If repo is specified or configured, find where it's checked out /** @type {any} */ let repoCwd = null; /** @type {any} */ let repoSlug = null; const patchWorkspacePath = typeof prConfig.patch_workspace_path === "string" ? prConfig.patch_workspace_path.trim() : ""; const currentCheckoutRepo = typeof prConfig.current_checkout_repo === "string" ? prConfig.current_checkout_repo.trim() : ""; const patchWorkspaceMatchesTargetRepo = patchWorkspacePath && patchWorkspaceTargetsRepo(repoResult.repo, currentCheckoutRepo); if (patchWorkspaceMatchesTargetRepo) { const patchWorkspaceResult = resolvePatchWorkspacePath(patchWorkspacePath); if (!patchWorkspaceResult.success) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: patchWorkspaceResult.error, }), }, ], isError: true, }; } repoCwd = patchWorkspaceResult.absolutePath; repoSlug = repoResult.repo; server.debug(`Using configured patch_workspace_path for create_pull_request: ${patchWorkspacePath} -> ${repoCwd}`); } const targetIsSideRepo = isSideRepoTarget(repoResult.repo); if (targetIsSideRepo) { server.debug(`create_pull_request: resolved target ${repoResult.repo} differs from workflow repository; requiring its checkout`); } else { server.debug("create_pull_request: resolved target matches workflow repository; no cross-repo checkout needed"); } if (targetIsSideRepo && !repoCwd) { // Use the validated/qualified repo slug from repoResult to avoid divergence // between the raw user input and the normalized/qualified repo name repoSlug = repoResult.repo; server.debug(`Multi-repo mode: looking for checkout of ${repoSlug}`); const checkoutResult = findRepoCheckout(repoSlug); if (!checkoutResult.success) { server.debug(`Failed to find repo checkout: ${checkoutResult.error}`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: checkoutResult.error, details: checkoutResult.error, }), }, ], isError: true, }; } repoCwd = checkoutResult.path; server.debug(`Found repo checkout at: ${repoCwd}`); } // Get base branch for the resolved target repository. // Priority: // 1. Explicit `base-branch` from the workflow config (no I/O, no fetch). // 2. Checkout manifest written by the workflow's setup phase (no network). // 3. Local origin/HEAD metadata + payload/API fallbacks via getBaseBranch. let baseBranch; const configuredBaseBranch = typeof prConfig.base_branch === "string" ? prConfig.base_branch.trim() : ""; if (configuredBaseBranch) { baseBranch = configuredBaseBranch; } else { const manifestEntry = lookupCheckout(repoResult.repo); if (manifestEntry && manifestEntry.default_branch) { baseBranch = manifestEntry.default_branch; server.debug(`Using checkout-manifest default_branch for ${repoResult.repo}: ${baseBranch}`); } else { baseBranch = await getBaseBranch(repoParts, { preferLocalDefaultBranchMetadata: Boolean(repoCwd), cwd: repoCwd || undefined, }); } } // Store the resolved base branch in the entry so the apply-time checkout step // can use it directly instead of inferring from event context. // This makes the safe output "self-describing" and fixes checkout for events // like issue_comment on PRs targeting non-default branches. entry.base_branch = baseBranch; server.debug(`create_pull_request: patch context repo=${repoResult.repo}, cwd=${repoCwd || process.env.GITHUB_WORKSPACE || process.cwd()}, baseBranch=${baseBranch}`); // If branch is not provided, is empty, or equals the base branch, use the current branch from git // This handles cases where the agent incorrectly passes the base branch instead of the working branch if (!entry.branch || entry.branch.trim() === "" || entry.branch === baseBranch) { const detectedBranch = getCurrentBranch(repoCwd); if (entry.branch === baseBranch) { server.debug(`Branch equals base branch (${baseBranch}), detecting actual working branch: ${detectedBranch}`); } else { server.debug(`Using current branch for create_pull_request: ${detectedBranch}`); } entry.branch = detectedBranch; } // Reject if branch still equals base_branch after detection. // This means the base branch was incorrectly resolved (e.g., resolved to the // feature branch itself due to a confused event context). Writing a safe output // in this state would cause a cryptic git exit-1 in the safe_outputs job when // it tries to fetch a non-existent remote ref. if (entry.branch === entry.base_branch) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Branch '${entry.branch}' equals base_branch '${entry.base_branch}'. Cannot create a pull request from a branch into itself. Ensure 'branch' is your feature branch and that the base branch resolves to the target (e.g., 'main' or 'master').`, }), }, ], isError: true, }; } const allowedBranches = parseAllowedBranchPatterns(prConfig.allowed_branches); if (allowedBranches.length > 0 && !isAllowedBranch(entry.branch, allowedBranches)) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Branch '${entry.branch}' does not match allowed-branches. Allowed patterns: ${allowedBranches.join(", ")}`, }), }, ], isError: true, }; } if (!normalizeBranchName(entry.branch)) { return buildIntentErrorResponse("Invalid branch name: provide a name containing more than dashes."); } const intentValidationError = validateCreatePullRequestIntent(entry); if (intentValidationError) { return buildIntentErrorResponse(intentValidationError); } // Check if allow-empty is enabled in configuration const allowEmpty = config.create_pull_request?.allow_empty === true; if (allowEmpty) { server.debug(`allow-empty is enabled for create_pull_request - skipping patch generation`); // Append the safe output entry without generating a patch appendSafeOutputCounted(entry); return { content: [ { type: "text", text: JSON.stringify({ result: "success", message: "Pull request prepared (allow-empty mode - no patch generated)", branch: entry.branch, }), }, ], }; } // Determine transport format: "bundle" (default) uses git bundle (preserves merge topology), // "am" uses git format-patch / git am (good for linear histories). // Use ?? (nullish coalescing) so an empty-string resolved value is preserved and // rejected below rather than silently falling back to "bundle". const patchFormat = prConfig["patch_format"] ?? config["patch_format"] ?? "bundle"; const validPatchFormats = ["am", "bundle"]; if (!validPatchFormats.includes(patchFormat)) { const errorMsg = `Invalid patch_format in configuration. Must be one of: ${validPatchFormats.join(", ")}`; server.debug(`create_pull_request: ${errorMsg}`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: errorMsg, }), }, ], isError: true, }; } const useBundle = patchFormat === "bundle"; // Build common options for both patch and bundle generation const transportOptions = { repoSlug: repoResult.repo }; if (repoCwd) { transportOptions.cwd = repoCwd; } // Pass per-handler token so cross-repo PATs are used for git fetch when configured. // Falls back to GITHUB_TOKEN if not set. if (prConfig["github-token"]) { transportOptions.token = prConfig["github-token"]; } // SECURITY: Pin the branch ref to a SHA before generating any transport artifacts. // This prevents TOCTOU races where the agent flips the ref between patch and bundle // generation, causing the two to represent different commit sets. const gitCwd = repoCwd || process.env.GITHUB_WORKSPACE || process.cwd(); ensureSafeDirectoryTrust(gitCwd, server); let pinnedSha; try { pinnedSha = execGitSync(["rev-parse", "--verify", `refs/heads/${entry.branch}^{commit}`], { cwd: gitCwd }) .toString() .trim(); server.debug(`Pinned branch '${entry.branch}' to SHA ${pinnedSha}`); } catch (pinError) { server.debug(`Failed to pin branch '${entry.branch}': ${getErrorMessage(pinError)}`); if (useBundle) { server.debug(`create_pull_request: proceeding without branch pinning for '${entry.branch}'; bundle generation will fall back to HEAD-based strategies when available`); } pinnedSha = null; } // Always generate a patch for policy enforcement (allowed-files/protected-files/excluded-files), // even when bundle transport is selected for apply-time commit transport. server.debug(`Generating patch for create_pull_request with branch: ${entry.branch}${repoCwd ? ` in ${repoCwd} baseBranch: ${baseBranch}` : ""}`); /** @type {Record} */ const patchOptions = { ...transportOptions }; if (patchWorkspaceMatchesTargetRepo) { patchOptions.workspacePath = patchWorkspacePath; } // Pass excluded_files so git excludes them via :(exclude) pathspecs at generation time. patchOptions.excludedFiles = getExcludedFiles(prConfig); // Pass pinnedSha so patch generation uses the pinned commit, not a potentially-flipped ref if (pinnedSha) { patchOptions.pinnedSha = pinnedSha; } const patchResult = await generateGitPatch(entry.branch, baseBranch, patchOptions); if (!patchResult.success) { // Patch generation failed or patch is empty const errorMsg = patchResult.error || "Failed to generate patch"; server.debug(`Patch generation failed: ${errorMsg}`); // Return error as content so the agent can see it, rather than throwing // which causes the tool call to fail silently in some MCP clients return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: errorMsg, details: "No commits were found to create a pull request. Make sure you have committed your changes using git add and git commit before calling create_pull_request.", }), }, ], isError: true, }; } // prettier-ignore server.debug(`Patch generated successfully: ${patchResult.patchPath} (${patchResult.patchSize} bytes, ${patchResult.patchLines} lines)`); // Patch/bundle paths are not transmitted via the safe-output entry: the // privileged safe_outputs job re-derives them from the (validated) branch name // using resolve_transport_paths. // Store the base commit SHA so the create_pull_request handler can use it // directly in the fallback path (the From header in format-patch output // contains the agent's commit SHA which won't exist in the target checkout) if (patchResult.baseCommit) { entry.base_commit = patchResult.baseCommit; } if (patchResult.excludedWorkflowFiles?.length > 0) { entry.body = `${WORKFLOW_COMPILATION_NOTICE}\n\n${entry.body || ""}`; } if (useBundle) { // Bundle transport: preserves merge commits and per-commit metadata server.debug(`Generating bundle for create_pull_request with branch: ${entry.branch}${repoCwd ? ` in ${repoCwd} baseBranch: ${baseBranch}` : ""}`); transportOptions.excludedFiles = getExcludedFiles(prConfig); const bundleResult = await generateGitBundle(entry.branch, baseBranch, transportOptions); if (!bundleResult.success) { const errorMsg = bundleResult.error || "Failed to generate bundle"; server.debug(`Bundle generation failed: ${errorMsg}`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: errorMsg, details: "No commits were found to create a pull request. Make sure you have committed your changes using git add and git commit before calling create_pull_request.", }), }, ], isError: true, }; } server.debug(`Bundle generated successfully: ${bundleResult.bundlePath} (${bundleResult.bundleSize} bytes)`); // SECURITY: Verify the branch ref hasn't been flipped between patch and bundle // generation (TOCTOU check). If the SHA changed, the bundle may contain different // commits than the patch used for file-protection policy enforcement. if (pinnedSha) { try { const currentSha = execGitSync(["rev-parse", "--verify", `refs/heads/${entry.branch}^{commit}`], { cwd: gitCwd }) .toString() .trim(); if (currentSha !== pinnedSha) { server.debug(`SECURITY: Branch '${entry.branch}' SHA changed during transport generation (was ${pinnedSha}, now ${currentSha}). Aborting.`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: "Branch ref changed during transport artifact generation. This may indicate a concurrent modification. Please retry.", details: `Branch '${entry.branch}' pointed to ${pinnedSha} at start but ${currentSha} after bundle generation.`, }), }, ], isError: true, }; } } catch (verifyError) { server.debug(`SECURITY: Failed to verify branch SHA after bundle generation: ${getErrorMessage(verifyError)}`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Failed to verify branch integrity after bundle generation: ${getErrorMessage(verifyError)}`, }), }, ], isError: true, }; } } // Bundle path is not transmitted via the safe-output entry: the privileged // safe_outputs job re-derives it from the (validated) branch name using // resolve_transport_paths. // Prefer the base_commit captured from format-patch generation (used by // patch-based fallback/apply paths). Only fall back to bundle base commit // when patch generation did not record one. if (!entry.base_commit && bundleResult.baseCommit) { entry.base_commit = bundleResult.baseCommit; } appendSafeOutputCounted(entry); return { content: [ { type: "text", text: JSON.stringify({ result: "success", patch: { path: patchResult.patchPath, size: patchResult.patchSize, lines: patchResult.patchLines, }, bundle: { path: bundleResult.bundlePath, size: bundleResult.bundleSize, }, }), }, ], }; } appendSafeOutputCounted(entry); return { content: [ { type: "text", text: JSON.stringify({ result: "success", patch: { path: patchResult.patchPath, size: patchResult.patchSize, lines: patchResult.patchLines, }, }), }, ], }; }; /** * Handler for push_to_pull_request_branch tool * Spec cross-reference: Safe Output Outcome Evaluation §17 (`push_to_pull_request_branch`). * The agent SHOULD supply a `branch` argument identifying the local branch it * committed onto. This is required for batch workflows that loop over multiple PRs * and checkout different branches; without it, the source branch would be inferred * from the current git HEAD which may not match the PR being processed. When * `branch` is omitted, it is derived from the current checkout as a fallback. * The destination branch is independently derived by the apply-time push handler * from pulls.get(pull_number).head.ref. * * The recorded triggering-PR baseline includes its trusted head repository, so * contributor forks that cannot be written are rejected before persisting an * output. The apply-time handler still validates full PR data independently. */ const pushToPullRequestBranchHandler = async args => { const entry = { ...(args || {}), type: "push_to_pull_request_branch" }; const wildcardTargetValidationError = validateWildcardTargetRequirement(entry); if (wildcardTargetValidationError) { return wildcardTargetValidationError; } // Resolve target repo configuration and validate the target repo early // This is needed before getBaseBranch to ensure we resolve the base branch // for the correct repository (especially in cross-repo scenarios) const pushConfig = config.push_to_pull_request_branch || {}; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(pushConfig); // Resolve and validate the target repository from the entry const repoResult = resolveAndValidateRepo(entry, defaultTargetRepo, allowedRepos, "push to PR branch"); if (!repoResult.success) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: repoResult.error, }), }, ], isError: true, }; } const { repoParts } = repoResult; const configuredHeadRepo = typeof pushConfig["head-repo"] === "string" ? pushConfig["head-repo"].trim() : ""; entry.head_repo = configuredHeadRepo || repoResult.repo; // Determine the working directory for git operations. // Look up the checkout path when the target repo is explicitly provided by the agent // or explicitly configured via target-repo in the workflow config — this ensures patch // generation runs from the correct directory when the target repo is checked out in a subdirectory. /** @type {any} */ let repoCwd = null; const itemRepo = repoResult.repo; const pushPatchWorkspacePath = typeof pushConfig.patch_workspace_path === "string" ? pushConfig.patch_workspace_path.trim() : ""; const pushCurrentCheckoutRepo = typeof pushConfig.current_checkout_repo === "string" ? pushConfig.current_checkout_repo.trim() : ""; const pushPatchWorkspaceMatchesTargetRepo = pushPatchWorkspacePath && patchWorkspaceTargetsRepo(itemRepo, pushCurrentCheckoutRepo); if (pushPatchWorkspaceMatchesTargetRepo) { const patchWorkspaceResult = resolvePatchWorkspacePath(pushPatchWorkspacePath); if (!patchWorkspaceResult.success) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: patchWorkspaceResult.error, }), }, ], isError: true, }; } repoCwd = patchWorkspaceResult.absolutePath; entry.repo_cwd = repoCwd; server.debug(`Using configured patch_workspace_path for push_to_pull_request_branch: ${pushPatchWorkspacePath} -> ${repoCwd}`); } const targetIsSideRepo = isSideRepoTarget(itemRepo); if (targetIsSideRepo && !repoCwd) { server.debug(`Looking for checkout of target repo: ${itemRepo}`); const checkoutResult = findRepoCheckout(itemRepo); if (!checkoutResult.success) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: checkoutResult.error, }), }, ], isError: true, }; } repoCwd = checkoutResult.path; entry.repo_cwd = repoCwd; server.debug(`Selected checkout folder for ${itemRepo}: ${repoCwd}`); } // Get base branch for the resolved target repository. // Priority: // 1. Explicit `base-branch` from the workflow config (no I/O, no fetch). // 2. Checkout manifest written by the workflow's setup phase (no network). // 3. Local origin/HEAD metadata in the side-repo checkout (when available). // 4. Payload / GitHub API fallbacks via getBaseBranch. let baseBranch; const configuredBaseBranch = typeof pushConfig.base_branch === "string" ? pushConfig.base_branch.trim() : ""; if (configuredBaseBranch) { baseBranch = configuredBaseBranch; server.debug(`Using configured base_branch for push_to_pull_request_branch: ${baseBranch}`); } else { const manifestEntry = lookupCheckout(itemRepo); if (manifestEntry && manifestEntry.default_branch) { baseBranch = manifestEntry.default_branch; server.debug(`Using checkout-manifest default_branch for ${itemRepo}: ${baseBranch}`); } else { baseBranch = await getBaseBranch(repoParts, { preferLocalDefaultBranchMetadata: Boolean(repoCwd), cwd: repoCwd || undefined, }); } } // Store the resolved base branch in the entry so the apply-time checkout step // can use it directly instead of inferring from event context. // This makes the safe output "self-describing" and fixes checkout for events // like issue_comment on PRs targeting non-default branches. entry.base_branch = baseBranch; // Use the agent-supplied branch if provided; fall back to the current checkout. // The agent-supplied value is authoritative: in multi-PR batch workflows the // working tree may be checked out to a different PR's branch by the time the // MCP handler runs, so relying solely on HEAD can produce a wrong source ref // and cause the apply step to fail with "couldn't find remote ref". // The apply-time push job re-derives the destination from pulls.get(pull_number) // independently, so this branch name is used only as the source ref for the // incremental diff against origin/. if (entry.branch && typeof entry.branch === "string" && entry.branch.trim()) { entry.branch = entry.branch.trim(); server.debug(`Using agent-supplied branch for push_to_pull_request_branch: ${entry.branch}`); } else { // Fallback: derive from the current checkout (backward compat for single-PR workflows // where the working tree is reliably on the PR head ref at call time). try { const detectedBranch = getCurrentBranch(repoCwd); server.debug(`Using current branch for push_to_pull_request_branch: ${detectedBranch}`); entry.branch = detectedBranch; } catch (branchErr) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Failed to determine source branch for push_to_pull_request_branch: ${getErrorMessage(branchErr)}. Either supply a 'branch' argument explicitly or ensure the working tree is checked out to the pull request's head ref before calling this tool.`, }), }, ], isError: true, }; } } // Reject if the detected branch equals base_branch. This means the workspace // is checked out on the PR's base (e.g. main) rather than the PR's head ref, // so there is nothing to push. Writing a safe output in this state would // cause a cryptic git exit-1 in the safe_outputs job when it tries to fetch // a non-existent remote ref. if (entry.branch === entry.base_branch) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Detected branch '${entry.branch}' equals base_branch '${entry.base_branch}'. The workspace is checked out on the base branch, not the pull request's head branch — there is nothing to push. Check out the PR's head ref and commit your changes there before calling push_to_pull_request_branch.`, }), }, ], isError: true, }; } if (!normalizeBranchName(entry.branch)) { return buildIntentErrorResponse("Invalid branch name: provide a name containing more than dashes."); } const intentValidationError = validatePushToPullRequestBranchIntent(entry); if (intentValidationError) { return buildIntentErrorResponse(intentValidationError); } // Resolve the effective target PR number so a recorded PR-head baseline can be // validated against it: branch name + repo alone don't uniquely identify a fork PR // (two forks can open identically-named branches against the same base repo). let effectivePushPRNumber = entry.pull_request_number != null ? parseInt(String(entry.pull_request_number), 10) : undefined; if (effectivePushPRNumber == null || Number.isNaN(effectivePushPRNumber)) { const pushInvocationContext = resolveInvocationContext(context); const { effectivePayload: pushEffectivePayload } = resolveEffectiveContext(pushInvocationContext, context); effectivePushPRNumber = pushEffectivePayload?.pull_request?.number || pushEffectivePayload?.issue?.number || undefined; } const prHeadBaseline = resolvePRHeadBaselineForPush(entry.branch, itemRepo, effectivePushPRNumber, server); const incrementalBaseRef = prHeadBaseline?.sha || prHeadBaseline?.ref || `refs/remotes/origin/${entry.branch}`; if (prHeadBaseline?.headRepo && prHeadBaseline.headRepo.toLowerCase() !== itemRepo.toLowerCase() && prHeadBaseline.headRepo.toLowerCase() !== configuredHeadRepo.toLowerCase()) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Cannot push to pull request #${effectivePushPRNumber}: its branch is in contributor fork '${prHeadBaseline.headRepo}', but safe-outputs.push-to-pull-request-branch.head-repo does not authorize that repository. A github-token or PAT alone does not authorize an unconfigured fork, even if the credential has write access. Configure head-repo as '${prHeadBaseline.headRepo}' with matching credentials to allow the push. Do not retry this push with the current configuration. If add_comment is available, comment on the pull request with the proposed code or patch; otherwise call report_incomplete with the proposed change and this error so the workflow can report it to the maintainer.`, }), }, ], isError: true, }; } // Build common options for both patch and bundle generation const pushTransportOptions = { mode: "incremental", repoSlug: repoResult.repo }; if (prHeadBaseline) { if (prHeadBaseline.ref) { pushTransportOptions.incrementalBaseRef = prHeadBaseline.ref; } if (prHeadBaseline.sha) { pushTransportOptions.incrementalBaseSha = prHeadBaseline.sha; } } if (repoCwd) { pushTransportOptions.cwd = repoCwd; } // Pass per-handler token so cross-repo PATs are used for git fetch when configured. // Falls back to GITHUB_TOKEN if not set. if (pushConfig["github-token"]) { pushTransportOptions.token = pushConfig["github-token"]; } // Determine transport format: "bundle" (default) uses git bundle (preserves merge topology), // "am" uses git format-patch / git am (good for linear histories). // Use ?? (nullish coalescing) so an empty-string resolved value is preserved and // rejected below rather than silently falling back to "bundle". // Track whether the user explicitly set patch_format so we can auto-fall-back // to bundle transport when merge commits are detected (since `git am` cannot // apply merge commits). When the user explicitly chose a format, respect it. const patchFormatExplicit = pushConfig["patch_format"] !== undefined || config["patch_format"] !== undefined; const pushPatchFormat = pushConfig["patch_format"] ?? config["patch_format"] ?? "bundle"; const validPushPatchFormats = ["am", "bundle"]; if (!validPushPatchFormats.includes(pushPatchFormat)) { const errorMsg = `Invalid patch_format in configuration. Must be one of: ${validPushPatchFormats.join(", ")}`; server.debug(`push_to_pull_request_branch: ${errorMsg}`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: errorMsg, }), }, ], isError: true, }; } let useBundle = pushPatchFormat === "bundle"; // Auto-fallback: when patch_format is not explicitly configured and the // incremental range (origin/..) contains merge commits, // automatically switch to bundle transport. `git am` cannot // apply merge commits, so without this fallback long-running branches that // periodically merge their base branch locally would fail with add/add // conflicts on every push attempt. The detection is best-effort and uses // only local refs (no extra fetch); a detection miss simply preserves the // existing behavior. if (!useBundle && !patchFormatExplicit && entry.branch) { const hasMerges = hasMergeCommitsInRange(incrementalBaseRef, entry.branch, { cwd: repoCwd || undefined }); if (hasMerges) { server.debug(`push_to_pull_request_branch: detected merge commit(s) in incremental range ${incrementalBaseRef}..${entry.branch}; auto-switching to bundle transport (set patch-format: am to override).`); useBundle = true; } } // SECURITY: Pin the branch ref to a SHA before generating any transport artifacts. // This prevents TOCTOU races where the agent flips the ref between patch and bundle // generation, causing the two to represent different commit sets. const pushGitCwd = repoCwd || process.env.GITHUB_WORKSPACE || process.cwd(); ensureSafeDirectoryTrust(pushGitCwd, server); let pushPinnedSha; try { pushPinnedSha = execGitSync(["rev-parse", "--verify", `refs/heads/${entry.branch}^{commit}`], { cwd: pushGitCwd }) .toString() .trim(); server.debug(`Pinned branch '${entry.branch}' to SHA ${pushPinnedSha}`); } catch (pinError) { server.debug(`Failed to pin branch '${entry.branch}': ${getErrorMessage(pinError)}`); if (useBundle) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Failed to pin branch '${entry.branch}' before bundle generation: ${getErrorMessage(pinError)}`, details: `Bundle transport requires branch pinning to prevent patch/bundle desynchronization. Retry after ensuring the branch exists locally (for example: git branch --list '${entry.branch}').`, }), }, ], isError: true, }; } pushPinnedSha = null; } // Validate the commits added after the existing PR head, not author-owned // commits already on the branch. Inspect each commit rather than only the net // diff so a disallowed file changed and reverted by the agent is still caught. if (Array.isArray(pushConfig.allowed_files) && pushConfig.allowed_files.length > 0) { try { // Use the pinned SHA to avoid racing a mutable local branch ref. if (!pushPinnedSha) { server.debug("Incremental allowed-files check skipped: branch SHA not pinned (non-bundle path)"); } else { // Use the transport's Git pathspecs so only files actually retained in // the generated changeset are subject to the allowlist. const excludedPathspecs = buildExcludePathspecs(getExcludedFiles(pushConfig)); const agentFiles = execGitSync(["log", "--ancestry-path", "--name-only", "--pretty=format:", `${incrementalBaseRef}..${pushPinnedSha}`, "--", ...excludedPathspecs.slice(1)], { cwd: pushGitCwd }) .toString() .split("\n") .map(s => s.trim()) .filter(Boolean); if (agentFiles.length > 0) { const allowedPatterns = pushConfig.allowed_files.map(p => globPatternToRegex(p)); const uniqueFiles = [...new Set(agentFiles)]; const disallowedFiles = uniqueFiles.filter(f => !allowedPatterns.some(re => re.test(f))); if (disallowedFiles.length > 0) { const sample = disallowedFiles.slice(0, 5); const remaining = disallowedFiles.length - sample.length; const filesStr = remaining > 0 ? `${sample.join(", ")} (+${remaining} more)` : sample.join(", "); server.debug(`Incremental allowed-files check failed: ${filesStr}`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Cannot push to pull request branch: the agent commits on '${entry.branch}' modify files outside the allowed-files configuration: ${filesStr}. Remove the disallowed file changes from your commits and retry, or update the allowed-files configuration to include these files.`, disallowed_files: disallowedFiles, }), }, ], isError: true, }; } } } } catch (incrementalCheckError) { // Non-fatal: if the PR head is not available locally or git fails, // skip this check and continue. The apply-time policy check in // push_to_pull_request_branch.cjs will still enforce allowed_files against // the incremental patch content. server.debug(`Incremental allowed-files check skipped (non-fatal): ${getErrorMessage(incrementalCheckError)}`); } } // Always generate an incremental patch for policy enforcement (allowed-files/protected-files/excluded-files), // even when bundle transport is selected for apply-time commit transport. server.debug(`Generating incremental patch for push_to_pull_request_branch with branch: ${entry.branch}, baseBranch: ${baseBranch}`); /** @type {Record} */ const pushPatchOptions = { ...pushTransportOptions }; if (pushPatchWorkspaceMatchesTargetRepo) { pushPatchOptions.workspacePath = pushPatchWorkspacePath; } // Pass excluded_files so git excludes them via :(exclude) pathspecs at generation time. pushPatchOptions.excludedFiles = getExcludedFiles(pushConfig); // Pass pinnedSha so patch generation uses the pinned commit, not a potentially-flipped ref if (pushPinnedSha) { pushPatchOptions.pinnedSha = pushPinnedSha; } const patchResult = await generateGitPatch(entry.branch, baseBranch, pushPatchOptions); if (!patchResult.success) { // Patch generation failed or patch is empty const errorMsg = patchResult.error || "Failed to generate patch"; server.debug(`Patch generation failed: ${errorMsg}`); // Return error as content so the agent can see it, rather than throwing // which causes the tool call to fail silently in some MCP clients return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: errorMsg, details: "No commits were found to push to the pull request branch. Make sure you have committed your changes using git add and git commit before calling push_to_pull_request_branch.", }), }, ], isError: true, }; } // prettier-ignore server.debug(`Patch generated successfully: ${patchResult.patchPath} (${patchResult.patchSize} bytes, ${patchResult.patchLines} lines, diffSize=${patchResult.diffSize ?? "(n/a)"} bytes)`); // Patch/bundle paths are not transmitted via the safe-output entry: the // privileged safe_outputs job re-derives them from the (validated) branch name // using resolve_transport_paths. // Store the base commit SHA so the push handler can use it directly if (patchResult.baseCommit) { entry.base_commit = patchResult.baseCommit; } if (patchResult.excludedWorkflowFiles?.length > 0) { entry.message = `${WORKFLOW_COMPILATION_NOTICE}\n\n${entry.message || ""}`; } // Store the incremental net diff size so push_to_pull_request_branch can // validate `max_patch_size` against the actual incremental change relative // to the existing PR branch head, not the (potentially much larger) size of // the format-patch transport file. This is critical for the long-running // branch pattern where the format-patch can include many // commits but each iteration only changes a few KB. if (typeof patchResult.diffSize === "number" && patchResult.diffSize >= 0) { entry.diff_size = patchResult.diffSize; } if (useBundle) { // Bundle transport: preserves merge commits and per-commit metadata server.debug(`Generating incremental bundle for push_to_pull_request_branch with branch: ${entry.branch}, baseBranch: ${baseBranch}`); pushTransportOptions.excludedFiles = getExcludedFiles(pushConfig); const bundleResult = await generateGitBundle(entry.branch, baseBranch, pushTransportOptions); if (!bundleResult.success) { const errorMsg = bundleResult.error || "Failed to generate bundle"; server.debug(`Bundle generation failed: ${errorMsg}`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: errorMsg, details: "No commits were found to push to the pull request branch. Make sure you have committed your changes using git add and git commit before calling push_to_pull_request_branch.", }), }, ], isError: true, }; } server.debug(`Bundle generated successfully: ${bundleResult.bundlePath} (${bundleResult.bundleSize} bytes)`); // SECURITY: Verify the branch ref hasn't been flipped between patch and bundle // generation (TOCTOU check). if (pushPinnedSha) { try { const currentSha = execGitSync(["rev-parse", "--verify", `refs/heads/${entry.branch}^{commit}`], { cwd: pushGitCwd }) .toString() .trim(); if (currentSha !== pushPinnedSha) { server.debug(`SECURITY: Branch '${entry.branch}' SHA changed during transport generation (was ${pushPinnedSha}, now ${currentSha}). Aborting.`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: "Branch ref changed during transport artifact generation. This may indicate a concurrent modification. Please retry.", details: `Branch '${entry.branch}' pointed to ${pushPinnedSha} at start but ${currentSha} after bundle generation.`, }), }, ], isError: true, }; } } catch (verifyError) { server.debug(`SECURITY: Failed to verify branch SHA after bundle generation: ${getErrorMessage(verifyError)}`); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Failed to verify branch integrity after bundle generation: ${getErrorMessage(verifyError)}`, }), }, ], isError: true, }; } } // Bundle path is not transmitted via the safe-output entry: the privileged // safe_outputs job re-derives it from the (validated) branch name using // resolve_transport_paths. // Prefer the base_commit captured from format-patch generation (used by // patch-based fallback/apply paths). Only fall back to bundle base commit // when patch generation did not record one. if (!entry.base_commit && bundleResult.baseCommit) { entry.base_commit = bundleResult.baseCommit; } appendSafeOutputCounted(entry); return { content: [ { type: "text", text: JSON.stringify({ result: "success", patch: { path: patchResult.patchPath, size: patchResult.patchSize, lines: patchResult.patchLines, }, bundle: { path: bundleResult.bundlePath, size: bundleResult.bundleSize, }, }), }, ], }; } appendSafeOutputCounted(entry); return { content: [ { type: "text", text: JSON.stringify({ result: "success", patch: { path: patchResult.patchPath, size: patchResult.patchSize, lines: patchResult.patchLines, }, }), }, ], }; }; /** * Handler for push_repo_memory tool * Spec cross-reference: not part of the numbered outcome types in Safe Output Outcome Evaluation v1.0.0. * Validates that memory files in the configured memory directory are within size limits. * Returns an error if any file or the total size exceeds the configured limits, * with guidance to reduce memory size before the workflow completes. */ const pushRepoMemoryHandler = args => { const memoryId = (args && args.memory_id) || "default"; const repoMemoryConfig = config.push_repo_memory; if (!repoMemoryConfig || !repoMemoryConfig.memories || repoMemoryConfig.memories.length === 0) { return { content: [ { type: "text", text: JSON.stringify({ result: "success", message: "No repo-memory configured." }), }, ], }; } // Find the memory config for the requested memory_id const memoryConf = repoMemoryConfig.memories.find(m => m.id === memoryId); if (!memoryConf) { const availableIds = repoMemoryConfig.memories.map(m => m.id).join(", "); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Memory ID '${memoryId}' not found. Available memory IDs: ${availableIds}`, }), }, ], isError: true, }; } const memoryDir = memoryConf.dir; const maxFileSize = memoryConf.max_file_size || 10240; const maxPatchSize = memoryConf.max_patch_size || 10240; const maxFileCount = memoryConf.max_file_count || 100; const allowedExtensions = Array.isArray(memoryConf.allowed_extensions) ? memoryConf.allowed_extensions : []; const fileGlobFilter = typeof memoryConf.file_glob === "string" ? memoryConf.file_glob : ""; const validationConfig = memoryConf.validation || null; const hasValidationScript = validationConfig && Object.prototype.hasOwnProperty.call(validationConfig, "script"); const hasJSONSchemas = validationConfig && Object.prototype.hasOwnProperty.call(validationConfig, "json_schemas"); const scriptRequired = validationConfig?.script_required === true; const schemasRequired = validationConfig?.json_schemas_required === true; const validationScript = hasValidationScript && typeof validationConfig.script === "string" ? validationConfig.script : ""; const jsonSchemas = hasJSONSchemas ? validationConfig.json_schemas : undefined; const validationTimeoutSeconds = validationConfig && Number.isFinite(validationConfig.timeout) ? validationConfig.timeout : undefined; // The effective limit is max_patch_size × 1.2, matching the push gate in push_repo_memory.cjs. // This catches cases where total memory content is close to or exceeds the push diff limit. const effectiveMaxPatchSize = Math.floor(maxPatchSize * 1.2); if (validationConfig && (typeof validationConfig !== "object" || Array.isArray(validationConfig))) { return buildIntentErrorResponse(`Repo-memory validation configuration for '${memoryId}' is malformed.`); } if (hasValidationScript && (typeof validationConfig.script !== "string" || !validationConfig.script)) { return buildIntentErrorResponse(`Repo-memory validation script configuration for '${memoryId}' is malformed.`); } if (scriptRequired && !hasValidationScript) { return buildIntentErrorResponse(`Repo-memory validation script configuration for '${memoryId}' is missing.`); } if (!fs.existsSync(memoryDir)) { if (hasJSONSchemas || schemasRequired) { const missingFileValidation = runCustomMemoryValidation({ jsonSchemas, requireJSONSchemas: true, memoryDir, memoryId, kind: "repo", }); return buildIntentErrorResponse(`Repo-memory schema validation failed for '${memoryId}': ${missingFileValidation.stderr}`); } return { content: [ { type: "text", text: JSON.stringify({ result: "success", message: `Memory directory '${memoryDir}' does not exist yet. No files to validate.` }), }, ], }; } // Persistence filters apply to validation and staging, but validation must // not delete files from the agent's working directory. const { compiledPatterns } = compileFileGlobPatterns(fileGlobFilter); const isEligibleFile = relativePath => isMemoryFileEligible(relativePath, allowedExtensions, compiledPatterns).eligible; clearValidationMarker("repo", memoryId); if (memoryConf.format_json === true) { try { const formattedFiles = formatJSONFiles(memoryDir, maxFileSize, isEligibleFile); if (formattedFiles.length > 0) { core.info(`Formatted ${formattedFiles.length} repo-memory JSON file(s) before validation: ${formattedFiles.join(", ")}`); } } catch (/** @type {any} */ error) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Failed to format repo-memory JSON before validation: ${getErrorMessage(error)}`, }), }, ], isError: true, }; } } // Recursively scan all files in the memory directory /** @type {Array<{relativePath: string, size: number}>} */ const files = []; /** * @param {string} dirPath * @param {string} relativePath */ function scanDir(dirPath, relativePath) { let entries; try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to read directory ${dirPath}: ${getErrorMessage(err)}`, { cause: err }); } for (const entry of entries) { // Skip .git directory to avoid counting git metadata as memory content. // The memory directory is a git clone, so .git may contain pack files that // grow with each commit and must not be counted toward the memory size limit. if (entry.isDirectory() && entry.name === ".git") { continue; } const fullPath = path.join(dirPath, entry.name); const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name; if (entry.isDirectory()) { scanDir(fullPath, relPath); } else if (entry.isFile()) { if (!isEligibleFile(relPath)) { continue; } let stats; try { stats = fs.statSync(fullPath); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to inspect file ${fullPath}: ${getErrorMessage(err)}`, { cause: err }); } files.push({ relativePath: relPath.replace(/\\/g, "/"), size: stats.size }); } } } try { scanDir(memoryDir, ""); } catch (/** @type {any} */ error) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Failed to scan memory directory: ${getErrorMessage(error)}`, }), }, ], isError: true, }; } // Check individual file sizes const oversizedFiles = files.filter(f => f.size > maxFileSize); if (oversizedFiles.length > 0) { const details = oversizedFiles.map(f => ` - ${f.relativePath} (${f.size} bytes > ${maxFileSize} bytes limit)`).join("\n"); return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `${oversizedFiles.length} file(s) exceed the maximum file size of ${maxFileSize} bytes (${Math.ceil(maxFileSize / 1024)} KB):\n${details}\n\n` + `Please reduce the size of these files before the workflow completes. Consider summarizing or truncating the content.`, }), }, ], isError: true, }; } // Check file count if (files.length > maxFileCount) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Too many files in memory: ${files.length} files exceeds the limit of ${maxFileCount} files.\n\n` + `Please reduce the number of files in '${memoryDir}' before the workflow completes.`, }), }, ], isError: true, }; } const totalSize = files.reduce((sum, f) => sum + f.size, 0); const totalSizeKb = Math.ceil(totalSize / 1024); const effectiveMaxKb = Math.floor(effectiveMaxPatchSize / 1024); const maxPatchSizeKb = Math.floor(maxPatchSize / 1024); let patchSizeBytes; try { ensureSafeDirectoryTrust(memoryDir, server); const stagedFiles = execGitSync(["diff", "--cached", "--no-renames", "--name-only", "-z"], { cwd: memoryDir, stdio: "pipe" }).split("\0").filter(Boolean).filter(isEligibleFile); const trackedFiles = execGitSync(["ls-files", "-z"], { cwd: memoryDir, stdio: "pipe" }).split("\0").filter(Boolean).filter(isEligibleFile); const filesToStage = [...new Set([...files.map(file => file.relativePath), ...trackedFiles])]; if (filesToStage.length > 0) { execGitSync(["add", "--sparse", "--all", "--", ...filesToStage.map(file => `:(literal)${file}`)], { cwd: memoryDir, stdio: "pipe" }); } const filesToMeasure = [...new Set([...stagedFiles, ...filesToStage])].map(file => `:(literal)${file}`); patchSizeBytes = getStagedPatchDiffSizeBytes({ execGitSyncFn: execGitSync, cwd: memoryDir, pathspecs: filesToMeasure }); } catch (/** @type {any} */ error) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Failed to compute staged patch diff size for '${memoryDir}': ${getErrorMessage(error)}`, }), }, ], isError: true, }; } const patchSizeKb = Math.ceil(patchSizeBytes / 1024); core.debug(`push_repo_memory validation: ${files.length} files, total ${totalSize} bytes, patch diff ${patchSizeBytes} bytes, effective limit ${effectiveMaxPatchSize} bytes`); if (patchSizeBytes > effectiveMaxPatchSize) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Patch diff size (${patchSizeKb} KB, ${patchSizeBytes} bytes) exceeds the allowed limit of ${effectiveMaxKb} KB ` + `(${effectiveMaxPatchSize} bytes, configured max-patch-size: ${maxPatchSizeKb} KB / ${maxPatchSize} bytes with 20% overhead).\n\n` + `Please reduce the size of staged changes in '${memoryDir}' before the workflow completes. ` + `Then call push_repo_memory again to verify the patch size is within limits.`, }), }, ], isError: true, }; } /** @type {ReturnType | null} */ let customValidation = null; if (validationConfig) { const validationDir = createEligibleMemoryValidationView(memoryDir, isEligibleFile); try { customValidation = runCustomMemoryValidation({ script: validationScript, jsonSchemas, requireJSONSchemas: Boolean(hasJSONSchemas || schemasRequired), memoryDir: validationDir, memoryId, kind: "repo", timeoutSeconds: validationTimeoutSeconds, }); } finally { try { fs.rmSync(validationDir, { recursive: true, force: true }); } catch (error) { throw new Error(`${ERR_SYSTEM}: Failed to clean up repo-memory validation directory: ${getErrorMessage(error)}`, { cause: error }); } } if (!customValidation.ok) { const reason = customValidation.timedOut ? `timed out after ${validationTimeoutSeconds || 30} second(s)` : `exited with code ${customValidation.exitCode}`; return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: `Repo-memory validation failed for '${memoryId}': ${reason}.`, storage_validation: { result: "success", message: `Storage validation passed: ${files.length} file(s), ${totalSizeKb} KB total content, ${patchSizeKb} KB patch diff (${patchSizeBytes} bytes).`, }, custom_validation: { result: "error", stdout: customValidation.stdout, stderr: customValidation.stderr, }, }), }, ], isError: true, }; } } const markerPath = writeValidationMarker("repo", memoryId); return { content: [ { type: "text", text: JSON.stringify({ result: "success", message: `Storage validation passed: ${files.length} file(s), ${totalSizeKb} KB total content, ` + `${patchSizeKb} KB patch diff (${patchSizeBytes} bytes) (limit: ${effectiveMaxKb} KB / ${effectiveMaxPatchSize} bytes).` + (customValidation ? " Custom domain validation passed." : ""), storage_validation: { result: "success", files: files.length, total_size_kb: totalSizeKb, patch_size_bytes: patchSizeBytes, effective_patch_limit_bytes: effectiveMaxPatchSize, }, custom_validation: customValidation ? { result: "success", stdout: customValidation.stdout, stderr: customValidation.stderr, } : undefined, validation_marker: markerPath, }), }, ], }; }; /** * Handler for create_issue tool * Applies title-based within-run deduplication for immediate feedback. */ const createIssueHandler = args => { const entry = { ...(args || {}), type: "create_issue" }; if (createIssueConfig.require_temporary_id === true && !entry.temporary_id) { return buildIntentErrorResponse(buildMissingTemporaryIdError("create_issue", "create-issue")); } const intentValidationError = validateCreateIssueIntent(entry, createIssueTitlePrefix); if (intentValidationError) { return buildIntentErrorResponse(intentValidationError); } const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(createIssueConfig); const repoResult = resolveAndValidateRepo(entry, defaultTargetRepo, allowedRepos, "issue"); if (!repoResult.success) { return { content: [ { type: "text", text: JSON.stringify({ result: "error", error: repoResult.error, }), }, ], isError: true, }; } const resolvedRepo = repoResult.repo; let resolvedTitle = entry.title?.trim() || ""; if (!resolvedTitle) { // Use the first non-empty line of the body as the title fallback rather than // the entire body, so the title stays concise and the body remains intact. const firstBodyLine = (entry.body || "") .split("\n") .map(l => l.replace(/^#+\s*/, "").trim()) .find(l => l.length > 0); resolvedTitle = firstBodyLine || "Agent Output"; } resolvedTitle = applyTitlePrefix(sanitizeTitle(resolvedTitle, createIssueTitlePrefix), createIssueTitlePrefix); if (deduplicateByTitle.enabled) { const normalizedTitle = normalizeTitleForDedup(resolvedTitle); const seenTitles = seenIssueTitlesByRepo.get(scopedKey(resolvedRepo)) || []; const duplicate = findDuplicateByTitle(normalizedTitle, seenTitles, deduplicateByTitle.maxDistance); if (duplicate) { const droppedEntry = { ...entry, _dropped_duplicate_by_title: true, _dedup_source: "mcp-within-run", _duplicate_title: duplicate.title, _duplicate_distance: duplicate.distance, }; const largeContentResponse = maybeHandleLargeContent(droppedEntry); if (!largeContentResponse) { appendSafeOutputCounted(droppedEntry); } return { content: [ { type: "text", text: JSON.stringify({ result: "duplicate_dropped", reason: `Duplicate create_issue title matched "${duplicate.title}" (distance=${duplicate.distance})`, }), }, ], }; } seenTitles.push({ title: resolvedTitle, normalizedTitle }); seenIssueTitlesByRepo.set(scopedKey(resolvedRepo), seenTitles); } const largeContentResponse = maybeHandleLargeContent(entry); if (largeContentResponse) return largeContentResponse; appendSafeOutputCounted(entry); return { content: [ { type: "text", text: JSON.stringify({ result: "success" }), }, ], }; }; const createWorkItemHandler = args => { const temporaryId = `#${generateTemporaryId()}`; const entry = { ...(args || {}), type: "ado_create_work_item", temporary_id: temporaryId }; appendSafeOutputCounted(entry); const output = { result: "success", temporary_id: temporaryId }; return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output, }; }; const createAzureDevOpsWorkItemHandler = type => args => { const entry = { ...(args || {}), type }; appendSafeOutputCounted(entry); return { content: [{ type: "text", text: JSON.stringify({ result: "success" }) }], }; }; const uploadWorkItemAttachmentHandler = args => { const entry = { ...(args || {}), type: "ado_upload_workitem_attachment" }; const rawPath = typeof entry.file_path === "string" ? entry.file_path.trim() : ""; if (!rawPath || path.isAbsolute(rawPath) || rawPath.includes(":")) { return buildIntentErrorResponse("ado_upload_workitem_attachment file_path must be a workspace-relative path without ':'"); } const segments = rawPath.split(/[\\/]+/); if (segments.some(segment => !segment || segment === "." || segment === "..")) { return buildIntentErrorResponse("ado_upload_workitem_attachment file_path must not contain empty, '.' or '..' path segments"); } const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd()); const sourcePath = path.resolve(workspace, ...segments); if (sourcePath !== workspace && !sourcePath.startsWith(workspace + path.sep)) { return buildIntentErrorResponse("ado_upload_workitem_attachment file_path resolves outside the workspace"); } let current = workspace; let sourceStat; try { for (const segment of segments) { current = path.join(current, segment); sourceStat = lstatGuard(current); if (!sourceStat) { return buildIntentErrorResponse("ado_upload_workitem_attachment does not accept symbolic links"); } } } catch (error) { return buildIntentErrorResponse(`ado_upload_workitem_attachment could not read file_path: ${getErrorMessage(error)}`); } if (!sourceStat?.isFile()) { return buildIntentErrorResponse("ado_upload_workitem_attachment file_path must identify one regular file"); } const attachmentConfig = getSafeOutputsToolConfig(config, "ado_upload_workitem_attachment"); const maxFileSize = Number(attachmentConfig.max_file_size || 5 * 1024 * 1024); if (!Number.isSafeInteger(maxFileSize) || maxFileSize < 1 || sourceStat.size > maxFileSize) { return buildIntentErrorResponse(`ado_upload_workitem_attachment file exceeds the configured max-file-size of ${maxFileSize} bytes`); } const allowedExtensions = Array.isArray(attachmentConfig.allowed_extensions) ? attachmentConfig.allowed_extensions : []; if (allowedExtensions.length > 0 && !allowedExtensions.some(extension => rawPath.toLowerCase().endsWith(String(extension).toLowerCase()))) { return buildIntentErrorResponse("ado_upload_workitem_attachment file extension is not allowed by the workflow configuration"); } try { const stagingRoot = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts"); const stagingDirectory = path.join(stagingRoot, "azure-devops-work-items"); fs.mkdirSync(stagingDirectory, { recursive: true, mode: 0o700 }); const stagedName = `${crypto.randomUUID()}-${segments[segments.length - 1]}`; const stagedPath = path.join(stagingDirectory, stagedName); fs.copyFileSync(sourcePath, stagedPath, fs.constants.COPYFILE_EXCL); fs.chmodSync(stagedPath, 0o600); entry.staged_file = path.posix.join("azure-devops-work-items", stagedName); } catch (error) { throw new Error(`${ERR_SYSTEM}: Failed to stage Azure DevOps work-item attachment: ${getErrorMessage(error)}`, { cause: error }); } entry.file_path = rawPath; appendSafeOutputCounted(entry); return { content: [{ type: "text", text: JSON.stringify({ result: "success", file_path: rawPath }) }], }; }; /** * Handler for create_project tool * Spec cross-reference: not part of the numbered outcome types in Safe Output Outcome Evaluation v1.0.0. * Auto-generates a temporary ID if not provided and returns it to the agent */ const createProjectHandler = args => { const entry = { ...(args || {}), type: "create_project" }; // Use helper to validate or generate temporary_id const tempIdResult = getOrGenerateTemporaryId(entry, "create_project"); if (tempIdResult.error) { throw { code: -32602, message: tempIdResult.error, }; } entry.temporary_id = tempIdResult.temporaryId; server.debug(`temporary_id for create_project: ${entry.temporary_id}`); // Append to safe outputs appendSafeOutputCounted(entry); // Return the temporary_id to the agent so it can reference this project return { content: [ { type: "text", text: JSON.stringify({ result: "success", temporary_id: entry.temporary_id, project: `#${entry.temporary_id}`, }), }, ], }; }; /** * Handler for add_comment tool * Spec cross-reference: Safe Output Outcome Evaluation §3 (`add_comment`). * Per Safe Outputs Specification MCE1: Enforces constraints during tool invocation * to provide immediate feedback to the LLM before recording to NDJSON * Also auto-generates a temporary_id if not provided and returns it to the agent */ const addCommentHandler = args => { const addCommentConfig = getSafeOutputsToolConfig(config, "add_comment"); // Validate comment constraints before appending to safe outputs // This provides early feedback per Requirement MCE1 (Early Validation) try { const body = (args && args.body) || ""; enforceCommentLimits(body, { maxLinks: addCommentConfig.max_links ?? addCommentConfig["max-links"], maxLength: MAX_COMMENT_INPUT_LENGTH }); } catch (error) { // Return validation error with specific constraint violation details // Per Requirement MCE3 (Actionable Error Responses) // Use JSON-RPC error code -32602 (Invalid params) per MCP specification throw { code: -32602, message: getErrorMessage(error), }; } // Refuse discussion-specific requests when discussions are not enabled in config. // reply_to_id is a discussion-only field; its presence unambiguously means the // agent is targeting a GitHub Discussion. Guard here (MCP phase) so the agent // gets immediate, actionable feedback rather than a late failure at execution time. const discussionsEnabled = addCommentConfig.discussions === true; const hasReplyToId = args?.reply_to_id != null && String(args.reply_to_id).trim() !== ""; if (hasReplyToId && !discussionsEnabled) { return buildIntentErrorResponse( "add_comment with reply_to_id targets a GitHub Discussion, but discussion comments are not enabled for this workflow. " + "Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request discussions:write permission." ); } const entry = { ...(args || {}), type: "add_comment" }; const commentIdValidationResult = validateAllowedAddCommentId(entry, addCommentConfig); if (commentIdValidationResult.error) { return commentIdValidationResult.error; } // Reject target:triggering early when no issue/PR/discussion target can be resolved. // Per Safe Outputs Specification MCE1: provides actionable feedback before writing to NDJSON. const effectiveAddCommentTarget = addCommentConfig.target || "triggering"; if (effectiveAddCommentTarget === "triggering") { /** @type {any} */ let invocationContext = null; try { invocationContext = resolveInvocationContext(context); } catch (err) { // A validation error (e.g. disallowed target_repo / SEC-005) is a real failure — surface it. const errMsg = getErrorMessage(err); if (errMsg.startsWith(ERR_VALIDATION)) { return buildIntentErrorResponse(errMsg); } // Unexpected structural error: skip validation and let downstream handle gracefully. } if (invocationContext != null) { const { effectiveEventName, effectivePayload } = resolveEffectiveContext(invocationContext, context); const isIssueCommentOnPR = effectiveEventName === "issue_comment" && Boolean(effectivePayload?.issue?.pull_request); const isIssueContext = effectiveEventName === "issues" || (effectiveEventName === "issue_comment" && !isIssueCommentOnPR); const isPRContext = PR_EVENT_NAMES.has(effectiveEventName) || isIssueCommentOnPR; const isDiscussionContext = effectiveEventName === "discussion" || effectiveEventName === "discussion_comment"; const triggeringNumber = isDiscussionContext ? effectivePayload?.discussion?.number : isPRContext ? effectivePayload?.pull_request?.number || (isIssueCommentOnPR ? effectivePayload?.issue?.number : null) : isIssueContext ? effectivePayload?.issue?.number : null; if (!triggeringNumber) { return buildIntentErrorResponse( `add_comment cannot resolve an issue, pull request, or discussion target from the "${effectiveEventName}" event payload. ` + `The add-comment handler uses target: triggering which only applies when an issue, pull request, or discussion triggered the workflow. ` + `To report results from this workflow, use create_discussion or create_issue instead. ` + `If you need to comment on a specific item, configure safe-outputs.add-comment.target to "*" and provide an explicit item_number.` ); } } } if (commentIdValidationResult.commentId === undefined) { // entry was spread from args, so a blank/whitespace comment_id (rather than an // absent one) could still be sitting on entry; strip it so downstream code never // sees an unvalidated raw value. delete entry.comment_id; } else { entry.comment_id = commentIdValidationResult.commentId; } const wildcardTargetValidationError = validateWildcardTargetRequirement(entry); if (wildcardTargetValidationError) { return wildcardTargetValidationError; } const intentValidationError = validateAddCommentIntent(entry); if (intentValidationError) { return buildIntentErrorResponse(intentValidationError); } // Use helper to validate or generate temporary_id const tempIdResult = getOrGenerateTemporaryId(entry, "add_comment"); if (tempIdResult.error) { throw { code: -32602, message: tempIdResult.error, }; } entry.temporary_id = tempIdResult.temporaryId; server.debug(`temporary_id for add_comment: ${entry.temporary_id}`); // Append to safe outputs appendSafeOutputCounted(entry); // Return the temporary_id to the agent so it can reference this comment return { content: [ { type: "text", text: JSON.stringify({ result: "success", temporary_id: entry.temporary_id, comment: `#${entry.temporary_id}`, }), }, ], }; }; /** * Session-scoped counter for buffered inline review comments. * Incremented by createPullRequestReviewCommentHandler, read by submitPullRequestReviewHandler * to guard against empty review submissions at the MCP server phase. */ const inlineReviewCommentCounts = new Map(); /** * Handler for create_pull_request_review_comment tool (MCP server phase). * Increments the session-scoped inline comment counter so that the subsequent * submitPullRequestReviewHandler can detect an otherwise-empty review. * Per Safe Outputs Specification MCE1: enforces constraints during tool invocation * to provide immediate feedback to the LLM before recording to NDJSON. */ const createPullRequestReviewCommentHandler = args => { const result = defaultHandler("create_pull_request_review_comment")(args); // Increment only after the default handler returns successfully; if it throws // (e.g. due to large-content rejection or an append write error) the counter // must not advance so the empty-review guard remains accurate. if (!result?.isError) { const key = scopedKey("review"); inlineReviewCommentCounts.set(key, (inlineReviewCommentCounts.get(key) || 0) + 1); } return result; }; /** * Handler for submit_pull_request_review tool (MCP server phase). * Validates the review before writing it to the NDJSON output so that the agent * receives an immediate MCP error rather than a silent 422 at finalization time. * * Checks performed: * 1. REQUEST_CHANGES requires a non-empty body (GitHub API requirement). * 2. If the review body is empty AND no inline comments were buffered during this * session, the review would be contentless and GitHub would return 422 — reject * early (mirrors Sub-pattern A guard in pr_review_buffer.cjs). * * Per Safe Outputs Specification MCE1: enforces constraints during tool invocation * to provide immediate feedback to the LLM before recording to NDJSON. */ const submitPullRequestReviewHandler = args => { const body = (args && typeof args.body === "string" ? args.body : "").trim(); const event = args && args.event ? String(args.event).toUpperCase() : "COMMENT"; const VALID_REVIEW_EVENTS = ["APPROVE", "REQUEST_CHANGES", "COMMENT"]; if (!VALID_REVIEW_EVENTS.includes(event)) { throw { code: -32602, message: `${ERR_VALIDATION}: submit_pull_request_review: invalid event '${args.event}'. Must be one of: ${VALID_REVIEW_EVENTS.join(", ")}`, }; } if (event === "REQUEST_CHANGES" && !body) { throw { code: -32602, message: `${ERR_VALIDATION}: submit_pull_request_review: 'body' is required when event is REQUEST_CHANGES`, }; } if (!body && !inlineReviewCommentCounts.get(scopedKey("review"))) { throw { code: -32602, message: `${ERR_VALIDATION}: submit_pull_request_review: review body is empty and no ` + `create_pull_request_review_comment calls were made — GitHub would return 422 for a contentless review. ` + `Provide a non-empty 'body' or call create_pull_request_review_comment before submitting.`, }; } // Reset the counter after a successful review submission so that subsequent // reviews in the same MCP session start with a clean slate. inlineReviewCommentCounts.delete(scopedKey("review")); return defaultHandler("submit_pull_request_review")(args); }; /** * Handler for dismiss_pull_request_review tool (MCP server phase). * Enforces justification minimum length and actor-author consistency before recording. */ const dismissPullRequestReviewHandler = args => { const justification = (args && typeof args.justification === "string" ? args.justification : "").trim(); if (justification.length < 20) { throw { code: -32602, message: `${ERR_VALIDATION}: dismiss_pull_request_review: 'justification' must be at least 20 characters`, }; } const actor = (process.env.GITHUB_ACTOR || context?.actor || "github-actions[bot]").trim() || "github-actions[bot]"; const author = args && typeof args.author === "string" ? args.author.trim() : ""; if (author && author !== actor) { throw { code: -32602, message: `${ERR_VALIDATION}: dismiss_pull_request_review: 'author' must match current workflow actor (${actor})`, }; } return defaultHandler("dismiss_pull_request_review")(args); }; /** * Resolve an allowed-root path to its canonical form, falling back to path.resolve when the * directory does not yet exist (e.g. GITHUB_WORKSPACE before checkout). * @param {string} root * @returns {string} */ function canonicalizeAllowedRoot(root) { try { return fs.realpathSync(root); } catch { return path.resolve(root); } } /** * Validate that a canonical absolute path does not refer to sensitive system or credential * locations. Returns an error message string, or null if the path is safe. * * Rejected patterns: * - Any path with a ".git" directory component (prevents .git/config leakage). * - System directories: /etc, /proc, /sys, /dev, /run, /boot, /lib*, /usr/lib*. * - HOME credential/config subtrees: .ssh, .aws, .netrc, .npmrc, .gitconfig, .gnupg, * .config, .docker, .kube, .azure, .gcp. * * @param {string} canonicalPath - Resolved absolute path (output of fs.realpathSync or path.resolve) * @returns {string|null} */ function validateUploadSourcePath(canonicalPath) { const parts = canonicalPath.split(path.sep); if (parts.some(p => p === ".git")) { return `path contains sensitive repository metadata (.git): ${canonicalPath}`; } const systemDenied = ["/etc", "/proc", "/sys", "/dev", "/run", "/boot", "/lib", "/lib64", "/usr/lib", "/usr/local/lib"]; for (const denied of systemDenied) { const normalDenied = path.resolve(denied); if (canonicalPath === normalDenied || canonicalPath.startsWith(normalDenied + path.sep)) { return `path refers to a system directory: ${canonicalPath}`; } } const homeDir = os.homedir(); if (homeDir) { const sensitiveNames = [".ssh", ".aws", ".gnupg", ".docker", ".kube", ".azure", ".gcp", ".config", ".netrc", ".npmrc", ".gitconfig", ".gitcredentials", ".git-credentials"]; for (const name of sensitiveNames) { const sensitive = path.join(path.resolve(homeDir), name); if (canonicalPath === sensitive || canonicalPath.startsWith(sensitive + path.sep)) { return `path refers to a sensitive HOME location: ${canonicalPath}`; } } } return null; } /** * Recursively copy all regular files from srcDir into destDir, preserving the relative * path structure under srcDir. Non-regular entries (sockets, devices, pipes, symlinks) * are skipped silently. * @param {string} srcDir - Absolute source directory path * @param {string} destDir - Absolute destination directory path */ function copyDirectoryRecursive(srcDir, destDir) { if (!fs.existsSync(destDir)) { try { fs.mkdirSync(destDir, { recursive: true }); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to create directory ${destDir}: ${getErrorMessage(err)}`, { cause: err }); } } let entries; try { entries = fs.readdirSync(srcDir, { withFileTypes: true }); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to read directory ${srcDir}: ${getErrorMessage(err)}`, { cause: err }); } for (const ent of entries) { const srcPath = path.join(srcDir, ent.name); const destPath = path.join(destDir, ent.name); if (ent.isDirectory()) { // Reject sensitive directory names at every level (e.g. foo/.git/config). let canonicalSrcPath; try { canonicalSrcPath = fs.realpathSync(srcPath); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to resolve canonical path for ${srcPath}: ${getErrorMessage(err)}`, { cause: err }); } const sensitiveErr = validateUploadSourcePath(canonicalSrcPath); if (sensitiveErr) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_artifact: ${sensitiveErr}`, }; } copyDirectoryRecursive(srcPath, destPath); } else if (ent.isFile() && !ent.isSymbolicLink() && !fs.existsSync(destPath)) { // Revalidate each file's canonical path before copying. let canonicalSrcPath; try { canonicalSrcPath = fs.realpathSync(srcPath); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to resolve canonical path for ${srcPath}: ${getErrorMessage(err)}`, { cause: err }); } const sensitiveErr = validateUploadSourcePath(canonicalSrcPath); if (sensitiveErr) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_artifact: ${sensitiveErr}`, }; } try { fs.copyFileSync(srcPath, destPath); fs.chmodSync(destPath, 0o600); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to copy file ${srcPath} to ${destPath}: ${getErrorMessage(err)}`, { cause: err }); } } // Skip symlinks, sockets, pipes, block/char devices — non-regular file types. } } /** * Handler for upload_artifact tool. * Spec cross-reference: not part of the numbered outcome types in Safe Output Outcome Evaluation v1.0.0. * * When the agent calls upload_artifact with an absolute path (e.g., * /tmp/gh-aw/python/charts/loc_by_language.png), the file lives only inside the * sandboxed container. After the container exits the file is gone, so the safe_outputs * job running on a different runner cannot find it. * * This handler copies the file (or directory) to the staging directory * ($RUNNER_TEMP/gh-aw/safeoutputs/upload-artifacts/), which is bind-mounted rw into * the container. The agent job then uploads that staging directory as the * safe-outputs-upload-artifacts artifact, and the safe_outputs job downloads it before * processing. * * For path-based requests with an absolute path (or a workspace-relative path that * does not already exist in staging) the handler also rewrites entry.path to the * staging-relative basename so that upload_artifact.cjs on the safe_outputs runner * resolves the file from staging rather than trying the (non-existent) original path. * * A bare filename or relative path that already exists in the staging directory is * left unchanged. Filter-based requests are passed through unchanged because the * agent is expected to have placed those files in staging directly. * * Critically, every path-based request is verified to resolve to an existing file or * directory before the entry is recorded: silently succeeding for a path that was * never actually staged would leave the workflow with a dangling reference to an * artifact that is never produced. */ const uploadArtifactHandler = args => { const entry = { ...(args || {}), type: "upload_artifact" }; if (typeof entry.path === "string") { // Enforce allowed canonical source roots: staging dir and GITHUB_WORKSPACE. // RUNNER_TEMP is intentionally excluded — only the specific staging subdirectory is allowed. const stagingRoot = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts"); const stagingDir = currentClaimHandle() ? claimArtifactPath(stagingRoot, currentClaimHandle()) : stagingRoot; let filePath = entry.path; if (!path.isAbsolute(filePath)) { const stagedCandidate = path.resolve(stagingDir, filePath); if (fs.existsSync(stagedCandidate)) { filePath = stagedCandidate; } else if (process.env.GITHUB_WORKSPACE) { filePath = path.resolve(process.env.GITHUB_WORKSPACE, filePath); } else { throw { code: -32602, message: `${ERR_VALIDATION}: upload_artifact: file not found: ${entry.path} (not present in staging directory ${stagingDir}, and GITHUB_WORKSPACE is not set)`, }; } } if (!fs.existsSync(filePath)) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_artifact: file not found: ${filePath}`, }; } const stat = lstatGuard(filePath); if (stat === null) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_artifact: symlinks are not allowed: ${filePath}`, }; } // Canonicalize to detect traversal escapes and symlink chains. let canonicalFilePath; try { canonicalFilePath = fs.realpathSync(filePath); } catch (err) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_artifact: failed to resolve canonical path for ${filePath}: ${getErrorMessage(err)}`, }; } // Reject sensitive paths (system dirs, .git, HOME credentials). const sensitiveError = validateUploadSourcePath(canonicalFilePath); if (sensitiveError) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_artifact: ${sensitiveError}`, }; } const allowedRoots = [canonicalizeAllowedRoot(stagingDir)]; if (process.env.GITHUB_WORKSPACE) { allowedRoots.push(canonicalizeAllowedRoot(process.env.GITHUB_WORKSPACE)); } const withinAllowedRoot = allowedRoots.some(root => canonicalFilePath === root || canonicalFilePath.startsWith(root + path.sep)); if (!withinAllowedRoot) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_artifact: path is outside allowed source roots (GITHUB_WORKSPACE, staging directory): ${canonicalFilePath}`, }; } if (!fs.existsSync(stagingDir)) { try { fs.mkdirSync(stagingDir, { recursive: true }); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to create directory ${stagingDir}: ${getErrorMessage(err)}`, { cause: err }); } } const canonicalStagingDir = canonicalizeAllowedRoot(stagingDir); if (canonicalFilePath === canonicalStagingDir) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_artifact: path must not be the staging directory itself; specify a file or directory inside it`, }; } const alreadyStaged = canonicalFilePath.startsWith(canonicalStagingDir + path.sep); const destName = path.basename(filePath); if (!alreadyStaged) { if (stat.isDirectory()) { copyDirectoryRecursive(filePath, path.join(stagingDir, destName)); } else { const destPath = path.join(stagingDir, destName); if (!fs.existsSync(destPath)) { try { fs.copyFileSync(filePath, destPath); fs.chmodSync(destPath, 0o600); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to copy file ${filePath} to ${destPath}: ${getErrorMessage(err)}`, { cause: err }); } } } server.debug(`upload_artifact: staged ${filePath} as ${destName}`); } // Rewrite to a staging-relative path so upload_artifact.cjs never receives // the agent job's absolute RUNNER_TEMP path. entry.path = alreadyStaged ? path.relative(canonicalStagingDir, canonicalFilePath).split(path.sep).join("/") : destName; } appendSafeOutputCounted(entry); const temporaryId = entry.temporary_id || null; return { content: [ { type: "text", text: JSON.stringify({ result: "success", ...(temporaryId ? { temporary_id: temporaryId } : {}), }), }, ], }; }; /** * Handler for upload_code_coverage tool. * Spec cross-reference: not part of the numbered outcome types in Safe Output Outcome Evaluation v1.0.0. * * Mirrors uploadArtifactHandler: when the agent calls upload_code_coverage with an * absolute path (or a workspace-relative path) for the coverage report file, this handler * copies it into the upload-code-coverage staging directory * ($RUNNER_TEMP/gh-aw/safeoutputs/upload-code-coverage/), which is uploaded as the * safe-outputs-upload-code-coverage artifact by the agent job and downloaded by the * dedicated upload_code_coverage job that invokes actions/upload-code-coverage. * * For requests with an absolute path, the handler also rewrites entry.file to the * staging-relative basename so upload_code_coverage.cjs on the safe_outputs runner * resolves the file from staging rather than trying the (non-existent) absolute path. * A bare filename already in staging is passed through unchanged. */ const uploadCodeCoverageHandler = args => { const entry = { ...(args || {}), type: "upload_code_coverage" }; const stagingRoot = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-code-coverage"); const stagingDir = currentClaimHandle() ? claimArtifactPath(stagingRoot, currentClaimHandle()) : stagingRoot; const coverageRoot = process.env.GITHUB_WORKSPACE ? path.join(process.env.GITHUB_WORKSPACE, "coverage") : ""; if (typeof entry.file === "string") { let filePath = entry.file; if (!path.isAbsolute(filePath)) { const stagedCandidate = path.resolve(stagingDir, filePath); if (fs.existsSync(stagedCandidate)) { filePath = stagedCandidate; } else if (process.env.GITHUB_WORKSPACE) { const workspaceResolvedPath = path.resolve(process.env.GITHUB_WORKSPACE, filePath); const resolvedCoverageRoot = path.resolve(process.env.GITHUB_WORKSPACE, "coverage"); const withinCoverageRoot = workspaceResolvedPath === resolvedCoverageRoot || workspaceResolvedPath.startsWith(resolvedCoverageRoot + path.sep); if (!withinCoverageRoot) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_code_coverage: path is outside allowed source roots (GITHUB_WORKSPACE/coverage, staging directory)`, }; } filePath = workspaceResolvedPath; } else { throw { code: -32602, message: `${ERR_VALIDATION}: upload_code_coverage: GITHUB_WORKSPACE is required when file is a relative path outside staging`, }; } } if (!fs.existsSync(filePath)) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_code_coverage: file not found: ${filePath}`, }; } const stat = lstatGuard(filePath); if (stat === null) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_code_coverage: symlinks are not allowed: ${filePath}`, }; } if (!stat.isFile()) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_code_coverage: path must be a regular file: ${filePath}`, }; } // Canonicalize to detect traversal escapes and symlink chains. let canonicalFilePath; try { canonicalFilePath = fs.realpathSync(filePath); } catch (err) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_code_coverage: failed to resolve canonical path for ${filePath}: ${getErrorMessage(err)}`, }; } // Reject sensitive paths (system dirs, .git, HOME credentials). const sensitiveError = validateUploadSourcePath(canonicalFilePath); if (sensitiveError) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_code_coverage: ${sensitiveError}`, }; } // Enforce allowed canonical source roots: staging dir and GITHUB_WORKSPACE/coverage. const allowedRoots = [canonicalizeAllowedRoot(stagingDir)]; if (coverageRoot) { allowedRoots.push(canonicalizeAllowedRoot(coverageRoot)); } const withinAllowedRoot = allowedRoots.some(root => canonicalFilePath === root || canonicalFilePath.startsWith(root + path.sep)); if (!withinAllowedRoot) { throw { code: -32602, message: `${ERR_VALIDATION}: upload_code_coverage: path is outside allowed source roots (GITHUB_WORKSPACE/coverage, staging directory): ${canonicalFilePath}`, }; } if (!fs.existsSync(stagingDir)) { try { fs.mkdirSync(stagingDir, { recursive: true }); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to create directory ${stagingDir}: ${getErrorMessage(err)}`, { cause: err }); } } const canonicalStagingDir = canonicalizeAllowedRoot(stagingDir); const alreadyStaged = canonicalFilePath === canonicalStagingDir || canonicalFilePath.startsWith(canonicalStagingDir + path.sep); const destName = path.basename(filePath); const destPath = path.join(stagingDir, destName); if (!alreadyStaged && !fs.existsSync(destPath)) { try { fs.copyFileSync(filePath, destPath); fs.chmodSync(destPath, 0o600); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to copy file ${filePath} to ${destPath}: ${getErrorMessage(err)}`, { cause: err }); } } // Rewrite to staging-relative path so upload_code_coverage.cjs resolves it from staging. entry.file = destName; server.debug(`upload_code_coverage: staged ${filePath} as ${destName}`); } appendSafeOutputCounted(entry); return { content: [ { type: "text", text: JSON.stringify({ result: "success" }), }, ], }; }; /** * Handler for update_issue tool * Spec cross-reference: Safe Output Outcome Evaluation §update_issue. * Per Safe Outputs Specification MCE1: Enforces context constraints during tool invocation * to provide immediate feedback to the LLM before recording to NDJSON. * Rejects `target: triggering` (the default) when the workflow has no issue context * (e.g. on schedule or push events), so the agent receives an actionable error * instead of a downstream Process Safe Outputs failure. */ const updateIssueHandler = args => { const normalizedArgs = normalizeBlankOptionalFields(args, safeOutputsToolMap.get("update_issue").inputSchema.properties); const updateFields = ["status", "title", "body", "labels", "assignees", "milestone"]; if (!updateFields.some(field => normalizedArgs[field] !== undefined && normalizedArgs[field] !== false && (normalizedArgs[field] !== null || field === "milestone"))) { throw { code: -32602, message: `${ERR_VALIDATION}: update_issue requires at least one of: ${updateFields.join(", ")} fields`, }; } const updateIssueConfig = getSafeOutputsToolConfig(config, "update_issue"); const effectiveTarget = updateIssueConfig.target || "triggering"; if (effectiveTarget === "triggering") { /** @type {any} */ let invocationContext = null; try { invocationContext = resolveInvocationContext(context); } catch (err) { // A validation error (e.g. disallowed target_repo / SEC-005) is a real failure — surface it. const errMsg = getErrorMessage(err); if (errMsg.startsWith(ERR_VALIDATION)) { return buildIntentErrorResponse(errMsg); } // Unexpected structural error: skip validation and let downstream handle gracefully. } if (invocationContext != null) { const { effectiveEventName, effectivePayload } = resolveEffectiveContext(invocationContext, context); const isIssueCommentOnPR = effectiveEventName === "issue_comment" && Boolean(effectivePayload?.issue?.pull_request); const isIssueContext = effectiveEventName === "issues" || (effectiveEventName === "issue_comment" && !isIssueCommentOnPR); if (!isIssueContext) { return buildIntentErrorResponse( `update_issue requires an issue context but the workflow is running on a "${effectiveEventName}" event. ` + `The update-issue handler uses target: triggering which only applies when an issue triggered the workflow. ` + `To report results from this workflow, use create_discussion or create_issue instead. ` + `If you need to update a specific issue, the workflow must configure update-issue: target: '*' and you must supply issue_number.` ); } } } return defaultHandler("update_issue")(normalizedArgs); }; const jiraCreateIssueHandler = args => { const temporaryId = `#${generateTemporaryId()}`; const entry = { ...(args || {}), type: "jira_create_issue", temporary_id: temporaryId }; appendSafeOutputCounted(entry); const output = { result: "success", temporary_id: temporaryId }; return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output, }; }; const jiraAddCommentHandler = defaultHandler("jira_add_comment"); const jiraAddLabelHandler = defaultHandler("jira_add_label"); const jiraUpdateIssueHandler = args => { const summary = typeof args?.summary === "string" ? args.summary.trim() : ""; const description = typeof args?.description === "string" ? args.description.trim() : ""; if (!summary && !description) { return buildIntentErrorResponse("jira_update_issue requires at least one non-empty field: summary or description"); } return defaultHandler("jira_update_issue")(args || {}); }; /** * Handler for update_pull_request tool * Spec cross-reference: Safe Output Outcome Evaluation §update_pull_request. * Per Safe Outputs Specification MCE1: Enforces constraints during tool invocation * to provide immediate feedback to the LLM before recording to NDJSON. * Uses hasUpdatePullRequestFields to validate that at least one of 'title', 'body', * or 'update_branch' is provided before recording to NDJSON. * Rejects `target: triggering` (the default) when the workflow has no pull request context * (e.g. on schedule or push events), so the agent receives an actionable error * instead of a downstream Process Safe Outputs failure. */ const updatePullRequestHandler = args => { /** @type {any} */ const normalizedArgs = normalizeCombinedTitleBodyArgs(args); if (!hasUpdatePullRequestFields(normalizedArgs)) { throw { code: -32602, message: `${ERR_VALIDATION}: update_pull_request requires at least one of: 'title', 'body', 'update_branch' fields`, }; } const updatePRConfig = getSafeOutputsToolConfig(config, "update_pull_request"); const effectivePRTarget = updatePRConfig.target || "triggering"; if (effectivePRTarget === "triggering") { /** @type {any} */ let invocationContext = null; try { invocationContext = resolveInvocationContext(context); } catch (err) { // A validation error (e.g. disallowed target_repo / SEC-005) is a real failure — surface it. const errMsg = getErrorMessage(err); if (errMsg.startsWith(ERR_VALIDATION)) { return buildIntentErrorResponse(errMsg); } // Unexpected structural error: skip validation and let downstream handle gracefully. } if (invocationContext != null) { const { effectiveEventName, effectivePayload } = resolveEffectiveContext(invocationContext, context); const isIssueCommentOnPR = effectiveEventName === "issue_comment" && Boolean(effectivePayload?.issue?.pull_request); const isPRContext = PR_EVENT_NAMES.has(effectiveEventName) || isIssueCommentOnPR; if (!isPRContext) { return buildIntentErrorResponse( `update_pull_request requires a pull request context but the workflow is running on a "${effectiveEventName}" event. ` + `The update-pull-request handler uses target: triggering which only applies when a pull request triggered the workflow. ` + `To report results from this workflow, use create_discussion or create_issue instead. ` + `If you need to update a specific pull request, the workflow must configure update-pull-request: target: '*' and you must supply pull_request_number.` ); } } } return defaultHandler("update_pull_request")(normalizedArgs); }; // ============================================================ // Egress context validators for tools that target existing items // ============================================================ // // Per Safe Outputs Specification MCE1: these handlers validate that the // required triggering context (PR, issue, or discussion) is available // BEFORE writing to NDJSON. When a tool targets a triggering entity // (no explicit item number supplied) on a scheduled or workflow_dispatch // run that has no such context, the agent receives an actionable error // immediately rather than a downstream processing hard-failure. // // Pattern: if an explicit target number is provided, bypass the context // check and let the downstream handler resolve it normally. Only when the // number is absent do we gate on triggering-context availability. /** * Build a handler that validates triggering context before recording to NDJSON. * Used for tools that fall back to triggering entity context when no explicit * target number is supplied (e.g. close_pull_request, close_issue, add_labels). * * Context validation only runs when the tool's configured `target` is `"triggering"` * (the default). When `target` is a fixed number (e.g. `"42"`) or `"*"` (wildcard), * the check is skipped: the fixed number is resolved downstream, and wildcard * enforcement is handled by `validateWildcardTargetRequirement`. * * @param {Object} opts * @param {string} opts.toolName - Normalised tool name (e.g. "close_pull_request") * @param {string[]} opts.explicitNumberFields - Args fields that constitute an explicit target * (if any is present the context check is skipped) * @param {"pr"|"issue"|"issue_or_pr"|"discussion"} opts.contextType - Required triggering context * @param {(eventName: string) => string} opts.buildErrorMessage - Returns the error string to * surface when the required context is missing * @returns {(args: any) => any} Handler function */ const createTriggeringContextHandler = ({ toolName, explicitNumberFields, contextType, buildErrorMessage }) => { return args => { const toolConfig = getSafeOutputsToolConfig(config, toolName); const effectiveTarget = toolConfig.target || "triggering"; // Only validate triggering context when the tool is configured to target the // triggering entity. With a fixed number target the downstream handler resolves // it directly; with wildcard targeting the per-call number requirement is // enforced by validateWildcardTargetRequirement in defaultHandler. if (effectiveTarget === "triggering") { // If the caller supplied an explicit target number, skip context validation. // The downstream execution handler will resolve the number normally. const hasExplicitNumber = explicitNumberFields.some(field => args?.[field] != null); if (!hasExplicitNumber) { /** @type {any} */ let invocationContext = null; try { invocationContext = resolveInvocationContext(context); } catch (err) { // A validation error (e.g. disallowed target_repo / SEC-005) is a real failure — surface it. const errMsg = getErrorMessage(err); if (errMsg.startsWith(ERR_VALIDATION)) { return buildIntentErrorResponse(errMsg); } // Unexpected structural error: skip validation and let downstream handle gracefully. } if (invocationContext != null) { const { effectiveEventName, effectivePayload } = resolveEffectiveContext(invocationContext, context); const isIssueCommentOnPR = effectiveEventName === "issue_comment" && Boolean(effectivePayload?.issue?.pull_request); let hasContext; if (contextType === "pr") { hasContext = PR_EVENT_NAMES.has(effectiveEventName) || isIssueCommentOnPR; } else if (contextType === "issue") { hasContext = effectiveEventName === "issues" || (effectiveEventName === "issue_comment" && !isIssueCommentOnPR); } else if (contextType === "issue_or_pr") { const isPR = PR_EVENT_NAMES.has(effectiveEventName) || isIssueCommentOnPR; const isIssue = effectiveEventName === "issues" || (effectiveEventName === "issue_comment" && !isIssueCommentOnPR); hasContext = isPR || isIssue; } else if (contextType === "discussion") { hasContext = effectiveEventName === "discussion" || effectiveEventName === "discussion_comment"; } else { hasContext = false; } if (!hasContext) { return buildIntentErrorResponse(buildErrorMessage(effectiveEventName)); } } } } return defaultHandler(toolName)(args || {}); }; }; /** * Handler for close_pull_request tool. * Per Safe Outputs Specification MCE1: validates PR context on egress when no * explicit pull_request_number is supplied. */ const closePullRequestHandler = createTriggeringContextHandler({ toolName: "close_pull_request", explicitNumberFields: ["pull_request_number"], contextType: "pr", buildErrorMessage: eventName => `close_pull_request requires a pull request context but the workflow is running on a "${eventName}" event. ` + `The close-pull-request handler auto-targets the pull request that triggered this workflow. ` + `To close a specific pull request, supply pull_request_number explicitly.`, }); /** * Handler for merge_pull_request tool. * Per Safe Outputs Specification MCE1: validates PR context on egress when no * explicit pull_request_number is supplied. */ const mergePullRequestHandler = createTriggeringContextHandler({ toolName: "merge_pull_request", explicitNumberFields: ["pull_request_number"], contextType: "pr", buildErrorMessage: eventName => `merge_pull_request requires a pull request context but the workflow is running on a "${eventName}" event. ` + `The merge-pull-request handler auto-targets the pull request that triggered this workflow. ` + `To merge a specific pull request, supply pull_request_number explicitly.`, }); /** * Handler for mark_pull_request_as_ready_for_review tool. * Per Safe Outputs Specification MCE1: validates PR context on egress when no * explicit pull_request_number is supplied. */ const markPullRequestAsReadyForReviewHandler = createTriggeringContextHandler({ toolName: "mark_pull_request_as_ready_for_review", explicitNumberFields: ["pull_request_number"], contextType: "pr", buildErrorMessage: eventName => `mark_pull_request_as_ready_for_review requires a pull request context but the workflow is running on a "${eventName}" event. ` + `This handler auto-targets the pull request that triggered this workflow. ` + `To target a specific pull request, supply pull_request_number explicitly.`, }); /** * Handler for add_reviewer tool. * Per Safe Outputs Specification MCE1: validates PR context on egress when no * explicit pull_request_number is supplied. */ const addReviewerHandler = createTriggeringContextHandler({ toolName: "add_reviewer", explicitNumberFields: ["pull_request_number"], contextType: "pr", buildErrorMessage: eventName => `add_reviewer requires a pull request context but the workflow is running on a "${eventName}" event. ` + `The add-reviewer handler auto-targets the pull request that triggered this workflow. ` + `To add a reviewer to a specific pull request, supply pull_request_number explicitly.`, }); /** * Handler for reply_to_pull_request_review_comment tool. * Per Safe Outputs Specification MCE1: validates PR context on egress when no * explicit pull_request_number is supplied. */ const replyToPullRequestReviewCommentHandler = createTriggeringContextHandler({ toolName: "reply_to_pull_request_review_comment", explicitNumberFields: ["pull_request_number"], contextType: "pr", buildErrorMessage: eventName => `reply_to_pull_request_review_comment requires a pull request context but the workflow is running on a "${eventName}" event. ` + `This handler auto-targets the pull request that triggered this workflow. ` + `To reply to a review comment on a specific pull request, supply pull_request_number explicitly.`, }); /** * Handler for close_issue tool. * Per Safe Outputs Specification MCE1: validates issue context on egress when no * explicit issue_number is supplied. */ const closeIssueHandler = createTriggeringContextHandler({ toolName: "close_issue", explicitNumberFields: ["issue_number"], contextType: "issue", buildErrorMessage: eventName => `close_issue requires an issue context but the workflow is running on a "${eventName}" event. ` + `The close-issue handler auto-targets the issue that triggered this workflow. ` + `To close a specific issue, supply issue_number explicitly.`, }); /** * Handler for add_labels tool. * Per Safe Outputs Specification MCE1: validates issue or PR context on egress when no * explicit item_number is supplied. */ const addLabelsHandler = createTriggeringContextHandler({ toolName: "add_labels", explicitNumberFields: ["item_number", "issue_number", "pr_number", "pull_number"], contextType: "issue_or_pr", buildErrorMessage: eventName => `add_labels requires an issue or pull request context but the workflow is running on a "${eventName}" event. ` + `The add-labels handler auto-targets the issue or pull request that triggered this workflow. ` + `To label a specific item, supply item_number explicitly.`, }); /** * Handler for remove_labels tool. * Per Safe Outputs Specification MCE1: validates issue or PR context on egress when no * explicit item_number is supplied. */ const removeLabelsHandler = createTriggeringContextHandler({ toolName: "remove_labels", explicitNumberFields: ["item_number", "issue_number", "pr_number", "pull_number"], contextType: "issue_or_pr", buildErrorMessage: eventName => `remove_labels requires an issue or pull request context but the workflow is running on a "${eventName}" event. ` + `The remove-labels handler auto-targets the issue or pull request that triggered this workflow. ` + `To remove labels from a specific item, supply item_number explicitly.`, }); /** * Handler for update_discussion tool. * Per Safe Outputs Specification MCE1: validates discussion context on egress when no * explicit discussion_number is supplied. */ const updateDiscussionHandler = createTriggeringContextHandler({ toolName: "update_discussion", explicitNumberFields: ["discussion_number"], contextType: "discussion", buildErrorMessage: eventName => `update_discussion requires a discussion context but the workflow is running on a "${eventName}" event. ` + `The update-discussion handler auto-targets the discussion that triggered this workflow. ` + `To update a specific discussion, supply discussion_number explicitly.`, }); /** * Handler for close_discussion tool. * Per Safe Outputs Specification MCE1: validates discussion context on egress when no * explicit discussion_number is supplied. */ const closeDiscussionHandler = createTriggeringContextHandler({ toolName: "close_discussion", explicitNumberFields: ["discussion_number"], contextType: "discussion", buildErrorMessage: eventName => `close_discussion requires a discussion context but the workflow is running on a "${eventName}" event. ` + `The close-discussion handler auto-targets the discussion that triggered this workflow. ` + `To close a specific discussion, supply discussion_number explicitly.`, }); const handlers = { defaultHandler, ledgerBuiltinHandler, ledgerAgentAppendHandler, uploadAssetHandler, uploadArtifactHandler, uploadCodeCoverageHandler, createPullRequestHandler, pushToPullRequestBranchHandler, pushRepoMemoryHandler, createIssueHandler, createWorkItemHandler, updateWorkItemHandler: createAzureDevOpsWorkItemHandler("ado_update_work_item"), commentOnWorkItemHandler: createAzureDevOpsWorkItemHandler("ado_comment_on_work_item"), assignWorkItemHandler: createAzureDevOpsWorkItemHandler("ado_assign_work_item"), linkWorkItemsHandler: createAzureDevOpsWorkItemHandler("ado_link_work_items"), uploadWorkItemAttachmentHandler, jiraCreateIssueHandler, jiraUpdateIssueHandler, jiraAddCommentHandler, jiraAddLabelHandler, createProjectHandler, addCommentHandler, createPullRequestReviewCommentHandler, submitPullRequestReviewHandler, dismissPullRequestReviewHandler, updateIssueHandler, updatePullRequestHandler, closePullRequestHandler, mergePullRequestHandler, markPullRequestAsReadyForReviewHandler, addReviewerHandler, replyToPullRequestReviewCommentHandler, closeIssueHandler, addLabelsHandler, removeLabelsHandler, updateDiscussionHandler, closeDiscussionHandler, }; const scopedHandler = (handler, type) => (...args) => { const executionHandle = currentClaimHandle(); const scope = executionHandle ? null : readClaimScopeContext(); if (!scope && !executionHandle) return handler(...args); const message = normalizeRuntimeMessage(type ? { ...(args[0] || {}), type } : args[0] || {}); if (type && !Object.hasOwn(args[0] || {}, "type")) delete message.type; if (executionHandle) return handler(message, ...args.slice(1)); return withClaimExecution({ ...scope, claim_handle: message.claim_handle }, () => handler(message, ...args.slice(1))); }; return Object.fromEntries( Object.entries(handlers).map(([name, handler]) => [ name, ["defaultHandler", "ledgerBuiltinHandler"].includes(name) ? (...args) => scopedHandler(Reflect.apply(handler, undefined, args), name === "defaultHandler" ? args[0] : "ledger_append") : scopedHandler(handler, name === "ledgerAgentAppendHandler" ? "ledger_append" : undefined), ]) ); } module.exports = { buildIntentErrorResponse, createHandlers, hasUpdatePullRequestFields, };