diff --git a/.changeset/patch-refactor-safe-output-validation.md b/.changeset/patch-refactor-safe-output-validation.md new file mode 100644 index 00000000000..97bd32cfcae --- /dev/null +++ b/.changeset/patch-refactor-safe-output-validation.md @@ -0,0 +1,12 @@ +--- +"gh-aw": patch +--- + +Refactor safe output type validation into a data-driven validator engine. + +Moves validation logic into `safe_output_type_validator.cjs`, generates +validation configuration from Go as a single source of truth, and updates +the JavaScript collector to use the new validator. Adds tests and keeps +the generated `validation.json` filtered and indented to reduce merge +conflicts. + diff --git a/.github/workflows/ai-triage-campaign.lock.yml b/.github/workflows/ai-triage-campaign.lock.yml index d6d58a95a65..3252cf7767f 100644 --- a/.github/workflows/ai-triage-campaign.lock.yml +++ b/.github/workflows/ai-triage-campaign.lock.yml @@ -460,6 +460,57 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "assign_to_agent": { + "defaultMax": 1, + "fields": { + "agent": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "issue_number": { + "required": true, + "positiveInteger": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2014,241 +2065,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2422,474 +2608,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/archie.lock.yml b/.github/workflows/archie.lock.yml index d213a97c8a5..73f13c84e03 100644 --- a/.github/workflows/archie.lock.yml +++ b/.github/workflows/archie.lock.yml @@ -1933,6 +1933,57 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3652,241 +3703,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4060,474 +4246,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index b2636bc4706..cdc512ed0b7 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -526,6 +526,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2177,241 +2236,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2585,474 +2779,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml index dbe424f705d..2ac6b72cb02 100644 --- a/.github/workflows/audit-workflows.lock.yml +++ b/.github/workflows/audit-workflows.lock.yml @@ -1328,6 +1328,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3648,241 +3716,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4056,474 +4259,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/blog-auditor.lock.yml b/.github/workflows/blog-auditor.lock.yml index 464734a5f83..5e57eb1760c 100644 --- a/.github/workflows/blog-auditor.lock.yml +++ b/.github/workflows/blog-auditor.lock.yml @@ -841,6 +841,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2780,241 +2839,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3188,474 +3382,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index b87467de876..c33fecc8aba 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -1817,6 +1817,57 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3444,241 +3495,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3852,474 +4038,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/breaking-change-checker.lock.yml b/.github/workflows/breaking-change-checker.lock.yml index 19975aa9666..7c1a697ff84 100644 --- a/.github/workflows/breaking-change-checker.lock.yml +++ b/.github/workflows/breaking-change-checker.lock.yml @@ -559,6 +559,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2234,241 +2300,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2642,474 +2843,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index 1e114a7b582..0f68767beba 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -1434,6 +1434,90 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "push_to_pull_request_branch": { + "defaultMax": 1, + "fields": { + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "pull_request_number": { + "issueOrPRNumber": true + } + } + }, + "update_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "operation": { + "type": "string", + "enum": [ + "replace", + "append", + "prepend" + ] + }, + "pull_request_number": { + "issueOrPRNumber": true + }, + "title": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + }, + "customValidation": "requiresOneOf:title,body" + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3154,241 +3238,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3562,474 +3781,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 0ea4bd779f6..a66d3441bd2 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -1209,6 +1209,86 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2886,241 +2966,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3294,474 +3509,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/cli-consistency-checker.lock.yml b/.github/workflows/cli-consistency-checker.lock.yml index 392cef856a4..2c9950163f5 100644 --- a/.github/workflows/cli-consistency-checker.lock.yml +++ b/.github/workflows/cli-consistency-checker.lock.yml @@ -568,6 +568,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2249,241 +2315,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2657,474 +2858,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/cli-version-checker.lock.yml b/.github/workflows/cli-version-checker.lock.yml index 8f15a465012..882de583310 100644 --- a/.github/workflows/cli-version-checker.lock.yml +++ b/.github/workflows/cli-version-checker.lock.yml @@ -734,6 +734,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2578,241 +2644,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2986,474 +3187,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index d95c2e028dc..f62acf00aa9 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -2246,6 +2246,106 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "push_to_pull_request_branch": { + "defaultMax": 1, + "fields": { + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "pull_request_number": { + "issueOrPRNumber": true + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -4180,241 +4280,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4588,474 +4823,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/close-old-discussions.lock.yml b/.github/workflows/close-old-discussions.lock.yml index e90ff26a761..a30fc5029e4 100644 --- a/.github/workflows/close-old-discussions.lock.yml +++ b/.github/workflows/close-old-discussions.lock.yml @@ -738,6 +738,66 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "close_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "discussion_number": { + "optionalPositiveInteger": true + }, + "reason": { + "type": "string", + "enum": [ + "RESOLVED", + "DUPLICATE", + "OUTDATED", + "ANSWERED" + ] + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2416,241 +2476,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2824,474 +3019,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/commit-changes-analyzer.lock.yml b/.github/workflows/commit-changes-analyzer.lock.yml index 6207f5052ce..013d2cd264d 100644 --- a/.github/workflows/commit-changes-analyzer.lock.yml +++ b/.github/workflows/commit-changes-analyzer.lock.yml @@ -801,6 +801,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2662,241 +2721,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3070,474 +3264,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/copilot-agent-analysis.lock.yml b/.github/workflows/copilot-agent-analysis.lock.yml index 38e30ac1248..849c5c57dc7 100644 --- a/.github/workflows/copilot-agent-analysis.lock.yml +++ b/.github/workflows/copilot-agent-analysis.lock.yml @@ -1149,6 +1149,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3344,241 +3403,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3752,474 +3946,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/copilot-pr-nlp-analysis.lock.yml b/.github/workflows/copilot-pr-nlp-analysis.lock.yml index a701a716a73..c502346f234 100644 --- a/.github/workflows/copilot-pr-nlp-analysis.lock.yml +++ b/.github/workflows/copilot-pr-nlp-analysis.lock.yml @@ -1355,6 +1355,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3716,241 +3784,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4124,474 +4327,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/copilot-pr-prompt-analysis.lock.yml b/.github/workflows/copilot-pr-prompt-analysis.lock.yml index 60746b182bd..6e77d75a347 100644 --- a/.github/workflows/copilot-pr-prompt-analysis.lock.yml +++ b/.github/workflows/copilot-pr-prompt-analysis.lock.yml @@ -864,6 +864,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2816,241 +2875,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3224,474 +3418,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/copilot-session-insights.lock.yml b/.github/workflows/copilot-session-insights.lock.yml index 556aade59c2..372adf8c5ac 100644 --- a/.github/workflows/copilot-session-insights.lock.yml +++ b/.github/workflows/copilot-session-insights.lock.yml @@ -2212,6 +2212,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -5268,241 +5336,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -5676,474 +5879,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/craft.lock.yml b/.github/workflows/craft.lock.yml index 5c244e8b44f..708c996b9db 100644 --- a/.github/workflows/craft.lock.yml +++ b/.github/workflows/craft.lock.yml @@ -2007,6 +2007,77 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "push_to_pull_request_branch": { + "defaultMax": 1, + "fields": { + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "pull_request_number": { + "issueOrPRNumber": true + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3771,241 +3842,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4179,474 +4385,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-code-metrics.lock.yml b/.github/workflows/daily-code-metrics.lock.yml index 2f3f177c049..c907f215d45 100644 --- a/.github/workflows/daily-code-metrics.lock.yml +++ b/.github/workflows/daily-code-metrics.lock.yml @@ -1380,6 +1380,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3794,241 +3853,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4202,474 +4396,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-doc-updater.lock.yml b/.github/workflows/daily-doc-updater.lock.yml index e4b6a78e0c4..f0f8462f037 100644 --- a/.github/workflows/daily-doc-updater.lock.yml +++ b/.github/workflows/daily-doc-updater.lock.yml @@ -691,6 +691,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2455,241 +2521,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2863,474 +3064,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-fact.lock.yml b/.github/workflows/daily-fact.lock.yml index d379ce559cb..aaeb5e05756 100644 --- a/.github/workflows/daily-fact.lock.yml +++ b/.github/workflows/daily-fact.lock.yml @@ -1023,6 +1023,57 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2540,241 +2591,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2948,474 +3134,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-file-diet.lock.yml b/.github/workflows/daily-file-diet.lock.yml index ebbdf976f8e..6a20e582440 100644 --- a/.github/workflows/daily-file-diet.lock.yml +++ b/.github/workflows/daily-file-diet.lock.yml @@ -719,6 +719,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2489,241 +2555,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2897,474 +3098,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 672f97fff4c..b69b50553c8 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -1080,6 +1080,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3222,241 +3290,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3630,474 +3833,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index cb33b198403..18ce0a5e430 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -1494,6 +1494,97 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "close_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "discussion_number": { + "optionalPositiveInteger": true + }, + "reason": { + "type": "string", + "enum": [ + "RESOLVED", + "DUPLICATE", + "OUTDATED", + "ANSWERED" + ] + } + } + }, + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3975,241 +4066,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4383,474 +4609,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-malicious-code-scan.lock.yml b/.github/workflows/daily-malicious-code-scan.lock.yml index 3e8095c8dab..cda6b5fbfe6 100644 --- a/.github/workflows/daily-malicious-code-scan.lock.yml +++ b/.github/workflows/daily-malicious-code-scan.lock.yml @@ -683,6 +683,84 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_code_scanning_alert": { + "defaultMax": 40, + "fields": { + "column": { + "optionalPositiveInteger": true + }, + "file": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "line": { + "required": true, + "positiveInteger": true + }, + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 2048 + }, + "ruleIdSuffix": { + "type": "string", + "sanitize": true, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$", + "patternError": "must contain only alphanumeric characters, hyphens, and underscores" + }, + "severity": { + "required": true, + "type": "string", + "enum": [ + "error", + "warning", + "info", + "note" + ] + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2463,241 +2541,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2871,474 +3084,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-multi-device-docs-tester.lock.yml b/.github/workflows/daily-multi-device-docs-tester.lock.yml index 7224e612cd7..2807967b50d 100644 --- a/.github/workflows/daily-multi-device-docs-tester.lock.yml +++ b/.github/workflows/daily-multi-device-docs-tester.lock.yml @@ -641,6 +641,81 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2345,241 +2420,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2753,474 +2963,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-news.lock.yml b/.github/workflows/daily-news.lock.yml index 169b853be5f..76b8a322bb1 100644 --- a/.github/workflows/daily-news.lock.yml +++ b/.github/workflows/daily-news.lock.yml @@ -1441,6 +1441,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3777,241 +3845,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4185,474 +4388,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-performance-summary.lock.yml b/.github/workflows/daily-performance-summary.lock.yml index 1f0a3f67423..52d16c74821 100644 --- a/.github/workflows/daily-performance-summary.lock.yml +++ b/.github/workflows/daily-performance-summary.lock.yml @@ -1266,6 +1266,97 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "close_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "discussion_number": { + "optionalPositiveInteger": true + }, + "reason": { + "type": "string", + "enum": [ + "RESOLVED", + "DUPLICATE", + "OUTDATED", + "ANSWERED" + ] + } + } + }, + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3577,241 +3668,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3985,474 +4211,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-repo-chronicle.lock.yml b/.github/workflows/daily-repo-chronicle.lock.yml index 61c59670e40..14e58ba2c27 100644 --- a/.github/workflows/daily-repo-chronicle.lock.yml +++ b/.github/workflows/daily-repo-chronicle.lock.yml @@ -1127,6 +1127,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3320,241 +3388,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3728,474 +3931,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/daily-team-status.lock.yml b/.github/workflows/daily-team-status.lock.yml index 2237a4214ce..5d58c0370b1 100644 --- a/.github/workflows/daily-team-status.lock.yml +++ b/.github/workflows/daily-team-status.lock.yml @@ -493,6 +493,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2071,241 +2130,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2479,474 +2673,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index e8bb0e8500c..abef1de769b 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -960,6 +960,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2943,241 +3011,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3351,474 +3554,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/dependabot-go-checker.lock.yml b/.github/workflows/dependabot-go-checker.lock.yml index ff2a6ad3774..76dc503973f 100644 --- a/.github/workflows/dependabot-go-checker.lock.yml +++ b/.github/workflows/dependabot-go-checker.lock.yml @@ -849,6 +849,86 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "close_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "issue_number": { + "optionalPositiveInteger": true + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2756,241 +2836,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3164,474 +3379,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/dev-hawk.lock.yml b/.github/workflows/dev-hawk.lock.yml index d2dda7205ec..11569000ec9 100644 --- a/.github/workflows/dev-hawk.lock.yml +++ b/.github/workflows/dev-hawk.lock.yml @@ -1079,6 +1079,57 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2684,241 +2735,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3092,474 +3278,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 1496612756a..5e8aaca08f7 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -478,6 +478,57 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "assign_to_agent": { + "defaultMax": 1, + "fields": { + "agent": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "issue_number": { + "required": true, + "positiveInteger": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2030,241 +2081,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2438,474 +2624,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/developer-docs-consolidator.lock.yml b/.github/workflows/developer-docs-consolidator.lock.yml index 268c288efb6..1ab44d23be1 100644 --- a/.github/workflows/developer-docs-consolidator.lock.yml +++ b/.github/workflows/developer-docs-consolidator.lock.yml @@ -1241,6 +1241,94 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3512,241 +3600,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3920,474 +4143,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/dictation-prompt.lock.yml b/.github/workflows/dictation-prompt.lock.yml index 7394af61ad9..3be707fb13a 100644 --- a/.github/workflows/dictation-prompt.lock.yml +++ b/.github/workflows/dictation-prompt.lock.yml @@ -541,6 +541,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2180,241 +2246,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2588,474 +2789,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/docs-noob-tester.lock.yml b/.github/workflows/docs-noob-tester.lock.yml index 51da1600a3d..557c86e3e7f 100644 --- a/.github/workflows/docs-noob-tester.lock.yml +++ b/.github/workflows/docs-noob-tester.lock.yml @@ -572,6 +572,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2257,241 +2325,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2665,474 +2868,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index e43da0360b3..2f05b4cdafb 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -624,6 +624,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2318,241 +2384,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2726,474 +2927,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/example-workflow-analyzer.lock.yml b/.github/workflows/example-workflow-analyzer.lock.yml index 26da57d584e..f5d7fd629b4 100644 --- a/.github/workflows/example-workflow-analyzer.lock.yml +++ b/.github/workflows/example-workflow-analyzer.lock.yml @@ -598,6 +598,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2240,241 +2299,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2648,474 +2842,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/github-mcp-structural-analysis.lock.yml b/.github/workflows/github-mcp-structural-analysis.lock.yml index ccfb6078575..c6f6280de67 100644 --- a/.github/workflows/github-mcp-structural-analysis.lock.yml +++ b/.github/workflows/github-mcp-structural-analysis.lock.yml @@ -1227,6 +1227,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3458,241 +3526,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3866,474 +4069,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/github-mcp-tools-report.lock.yml b/.github/workflows/github-mcp-tools-report.lock.yml index 98094b7a34c..5f437d32abd 100644 --- a/.github/workflows/github-mcp-tools-report.lock.yml +++ b/.github/workflows/github-mcp-tools-report.lock.yml @@ -1091,6 +1091,94 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3220,241 +3308,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3628,474 +3851,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/glossary-maintainer.lock.yml b/.github/workflows/glossary-maintainer.lock.yml index d96514802b8..be6ff25b812 100644 --- a/.github/workflows/glossary-maintainer.lock.yml +++ b/.github/workflows/glossary-maintainer.lock.yml @@ -1062,6 +1062,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3229,241 +3295,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3637,474 +3838,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/go-logger.lock.yml b/.github/workflows/go-logger.lock.yml index 9373e6f5615..1f056030bbb 100644 --- a/.github/workflows/go-logger.lock.yml +++ b/.github/workflows/go-logger.lock.yml @@ -818,6 +818,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2684,241 +2750,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3092,474 +3293,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index 6fdcb0661e3..332f9475493 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -688,6 +688,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2369,241 +2435,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2777,474 +2978,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index 91ce365a247..3c0131f701f 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -1913,6 +1913,87 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "create_pull_request_review_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "line": { + "required": true, + "positiveInteger": true + }, + "path": { + "required": true, + "type": "string" + }, + "side": { + "type": "string", + "enum": [ + "LEFT", + "RIGHT" + ] + }, + "start_line": { + "optionalPositiveInteger": true + } + }, + "customValidation": "startLineLessOrEqualLine" + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3566,241 +3647,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3974,474 +4190,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/instructions-janitor.lock.yml b/.github/workflows/instructions-janitor.lock.yml index 2dea5042f67..d16726c24ce 100644 --- a/.github/workflows/instructions-janitor.lock.yml +++ b/.github/workflows/instructions-janitor.lock.yml @@ -688,6 +688,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2450,241 +2516,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2858,474 +3059,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/issue-arborist.lock.yml b/.github/workflows/issue-arborist.lock.yml index 8cba778edcf..f1e5d2f1995 100644 --- a/.github/workflows/issue-arborist.lock.yml +++ b/.github/workflows/issue-arborist.lock.yml @@ -662,6 +662,79 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "link_sub_issue": { + "defaultMax": 5, + "fields": { + "parent_issue_number": { + "required": true, + "issueNumberOrTemporaryId": true + }, + "sub_issue_number": { + "required": true, + "issueNumberOrTemporaryId": true + } + }, + "customValidation": "parentAndSubDifferent" + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2328,241 +2401,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2736,474 +2944,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 691cbe6beda..cc23dd79c30 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -1707,6 +1707,58 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueOrPRNumber": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3203,241 +3255,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3611,474 +3798,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 1c9bceb12ba..51e2781ef7e 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -1098,6 +1098,71 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "assign_to_agent": { + "defaultMax": 1, + "fields": { + "agent": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "issue_number": { + "required": true, + "positiveInteger": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2693,241 +2758,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3101,474 +3301,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/issue-triage-agent.lock.yml b/.github/workflows/issue-triage-agent.lock.yml index 5e58fc183a2..24b3fbb390f 100644 --- a/.github/workflows/issue-triage-agent.lock.yml +++ b/.github/workflows/issue-triage-agent.lock.yml @@ -876,6 +876,58 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueOrPRNumber": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2360,241 +2412,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2768,474 +2955,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/lockfile-stats.lock.yml b/.github/workflows/lockfile-stats.lock.yml index 97d3a3c95e9..e4cd3db81f0 100644 --- a/.github/workflows/lockfile-stats.lock.yml +++ b/.github/workflows/lockfile-stats.lock.yml @@ -915,6 +915,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2898,241 +2957,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3306,474 +3500,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index 85b2e22607b..48fa8160063 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -753,6 +753,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2862,241 +2921,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3270,474 +3464,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/mergefest.lock.yml b/.github/workflows/mergefest.lock.yml index cf301c3664a..25e25ee8ae9 100644 --- a/.github/workflows/mergefest.lock.yml +++ b/.github/workflows/mergefest.lock.yml @@ -1105,6 +1105,63 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "push_to_pull_request_branch": { + "defaultMax": 1, + "fields": { + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "pull_request_number": { + "issueOrPRNumber": true + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2952,241 +3009,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3360,474 +3552,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/notion-issue-summary.lock.yml b/.github/workflows/notion-issue-summary.lock.yml index c62e2e599c2..a7edfe6b74e 100644 --- a/.github/workflows/notion-issue-summary.lock.yml +++ b/.github/workflows/notion-issue-summary.lock.yml @@ -358,6 +358,43 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -1877,241 +1914,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2285,474 +2457,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index 19d587448f1..e77e2ef00c3 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -1928,6 +1928,57 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3612,241 +3663,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4020,474 +4206,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index e280d9ca0a1..86b8ef04fcd 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -1311,6 +1311,95 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "close_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "discussion_number": { + "optionalPositiveInteger": true + }, + "reason": { + "type": "string", + "enum": [ + "RESOLVED", + "DUPLICATE", + "OUTDATED", + "ANSWERED" + ] + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2927,241 +3016,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3335,474 +3559,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 4440a6c93c6..a1e8f3897b0 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -2698,6 +2698,215 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueOrPRNumber": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "create_pull_request_review_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "line": { + "required": true, + "positiveInteger": true + }, + "path": { + "required": true, + "type": "string" + }, + "side": { + "type": "string", + "enum": [ + "LEFT", + "RIGHT" + ] + }, + "start_line": { + "optionalPositiveInteger": true + } + }, + "customValidation": "startLineLessOrEqualLine" + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "push_to_pull_request_branch": { + "defaultMax": 1, + "fields": { + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "pull_request_number": { + "issueOrPRNumber": true + } + } + }, + "update_issue": { + "defaultMax": 1, + "fields": { + "body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "issue_number": { + "issueOrPRNumber": true + }, + "status": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + }, + "customValidation": "requiresOneOf:status,title,body" + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -4312,241 +4521,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4720,474 +5064,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/pr-nitpick-reviewer.lock.yml b/.github/workflows/pr-nitpick-reviewer.lock.yml index bfe743541a8..34e829f89f8 100644 --- a/.github/workflows/pr-nitpick-reviewer.lock.yml +++ b/.github/workflows/pr-nitpick-reviewer.lock.yml @@ -1923,6 +1923,109 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "create_pull_request_review_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "line": { + "required": true, + "positiveInteger": true + }, + "path": { + "required": true, + "type": "string" + }, + "side": { + "type": "string", + "enum": [ + "LEFT", + "RIGHT" + ] + }, + "start_line": { + "optionalPositiveInteger": true + } + }, + "customValidation": "startLineLessOrEqualLine" + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3882,241 +3985,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4290,474 +4528,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/prompt-clustering-analysis.lock.yml b/.github/workflows/prompt-clustering-analysis.lock.yml index 13a115017fe..2d675e0f950 100644 --- a/.github/workflows/prompt-clustering-analysis.lock.yml +++ b/.github/workflows/prompt-clustering-analysis.lock.yml @@ -1599,6 +1599,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -4106,241 +4165,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4514,474 +4708,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/python-data-charts.lock.yml b/.github/workflows/python-data-charts.lock.yml index 24e7cd41359..e17d5206bd2 100644 --- a/.github/workflows/python-data-charts.lock.yml +++ b/.github/workflows/python-data-charts.lock.yml @@ -1429,6 +1429,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3947,241 +4015,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4355,474 +4558,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index 519b002dc72..4b54530d3ff 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -2218,6 +2218,86 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -4163,241 +4243,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4571,474 +4786,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index 65533ecb909..79dc20bfd88 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -630,6 +630,68 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "update_release": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "operation": { + "required": true, + "type": "string", + "enum": [ + "replace", + "append", + "prepend" + ] + }, + "tag": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2258,241 +2320,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2666,474 +2863,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/repo-tree-map.lock.yml b/.github/workflows/repo-tree-map.lock.yml index 328f998d72d..816756bfcae 100644 --- a/.github/workflows/repo-tree-map.lock.yml +++ b/.github/workflows/repo-tree-map.lock.yml @@ -571,6 +571,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2255,241 +2314,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2663,474 +2857,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/repository-quality-improver.lock.yml b/.github/workflows/repository-quality-improver.lock.yml index dadb95ea3e8..dcd39b47347 100644 --- a/.github/workflows/repository-quality-improver.lock.yml +++ b/.github/workflows/repository-quality-improver.lock.yml @@ -1027,6 +1027,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3162,241 +3221,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3570,474 +3764,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/research.lock.yml b/.github/workflows/research.lock.yml index 266b5e6e6dc..fd66f7cfdda 100644 --- a/.github/workflows/research.lock.yml +++ b/.github/workflows/research.lock.yml @@ -502,6 +502,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2114,241 +2173,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2522,474 +2716,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/safe-output-health.lock.yml b/.github/workflows/safe-output-health.lock.yml index cff364eb0f5..b8907268709 100644 --- a/.github/workflows/safe-output-health.lock.yml +++ b/.github/workflows/safe-output-health.lock.yml @@ -1040,6 +1040,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3129,241 +3188,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3537,474 +3731,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/schema-consistency-checker.lock.yml b/.github/workflows/schema-consistency-checker.lock.yml index 72e3561a0bb..6e7ed0037e2 100644 --- a/.github/workflows/schema-consistency-checker.lock.yml +++ b/.github/workflows/schema-consistency-checker.lock.yml @@ -925,6 +925,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2909,241 +2968,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3317,474 +3511,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index 815c3ea67bb..45c00eaf1b6 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -2240,6 +2240,57 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -4235,241 +4286,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4643,474 +4829,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/security-fix-pr.lock.yml b/.github/workflows/security-fix-pr.lock.yml index 89489d2db5d..a5f0c5046b9 100644 --- a/.github/workflows/security-fix-pr.lock.yml +++ b/.github/workflows/security-fix-pr.lock.yml @@ -665,6 +665,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2385,241 +2451,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2793,474 +2994,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/semantic-function-refactor.lock.yml b/.github/workflows/semantic-function-refactor.lock.yml index 8399e940a9c..3a8185623db 100644 --- a/.github/workflows/semantic-function-refactor.lock.yml +++ b/.github/workflows/semantic-function-refactor.lock.yml @@ -1055,6 +1055,86 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "close_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "issue_number": { + "optionalPositiveInteger": true + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3140,241 +3220,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3548,474 +3763,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index bffc15a8cf4..27891fc6087 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -2279,6 +2279,101 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueOrPRNumber": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -4022,241 +4117,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4430,474 +4660,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index b148d99bd9c..2051451a7f2 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -2049,6 +2049,101 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueOrPRNumber": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3580,241 +3675,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3988,474 +4218,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/smoke-copilot-no-firewall.lock.yml b/.github/workflows/smoke-copilot-no-firewall.lock.yml index 733427164b6..ff857e64816 100644 --- a/.github/workflows/smoke-copilot-no-firewall.lock.yml +++ b/.github/workflows/smoke-copilot-no-firewall.lock.yml @@ -2095,6 +2095,128 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueOrPRNumber": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "update_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "operation": { + "type": "string", + "enum": [ + "replace", + "append", + "prepend" + ] + }, + "pull_request_number": { + "issueOrPRNumber": true + }, + "title": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + }, + "customValidation": "requiresOneOf:title,body" + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3637,241 +3759,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4045,474 +4302,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 39eab7103a4..5c4497a194d 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -2123,6 +2123,101 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueOrPRNumber": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3668,241 +3763,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4076,474 +4306,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/smoke-detector.lock.yml b/.github/workflows/smoke-detector.lock.yml index bd41eee09ca..14266bbd0c4 100644 --- a/.github/workflows/smoke-detector.lock.yml +++ b/.github/workflows/smoke-detector.lock.yml @@ -1927,6 +1927,86 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3820,241 +3900,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4228,474 +4443,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/smoke-srt.lock.yml b/.github/workflows/smoke-srt.lock.yml index 3bdaf0bfc56..4711a259ddd 100644 --- a/.github/workflows/smoke-srt.lock.yml +++ b/.github/workflows/smoke-srt.lock.yml @@ -467,6 +467,43 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2162,241 +2199,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2570,474 +2742,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 06eefb82b91..913b5c4ec51 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -949,6 +949,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2934,241 +2993,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3342,474 +3536,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/super-linter.lock.yml b/.github/workflows/super-linter.lock.yml index eab8b4f5761..9e10c538b47 100644 --- a/.github/workflows/super-linter.lock.yml +++ b/.github/workflows/super-linter.lock.yml @@ -675,6 +675,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2390,241 +2456,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2798,474 +2999,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 06650720acd..ad753099ea9 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -1508,6 +1508,95 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3441,241 +3530,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3849,474 +4073,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index 303e9787272..6a0682027ab 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -955,6 +955,92 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "push_to_pull_request_branch": { + "defaultMax": 1, + "fields": { + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "pull_request_number": { + "issueOrPRNumber": true + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2568,241 +2654,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2976,474 +3197,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/typist.lock.yml b/.github/workflows/typist.lock.yml index 79f8387a36e..9b887141cf2 100644 --- a/.github/workflows/typist.lock.yml +++ b/.github/workflows/typist.lock.yml @@ -1061,6 +1061,65 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3192,241 +3251,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3600,474 +3794,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index af8e2e86487..84827f63919 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -1963,6 +1963,95 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3948,241 +4037,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -4356,474 +4580,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/video-analyzer.lock.yml b/.github/workflows/video-analyzer.lock.yml index ac6ba478422..140a42037bd 100644 --- a/.github/workflows/video-analyzer.lock.yml +++ b/.github/workflows/video-analyzer.lock.yml @@ -673,6 +673,72 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -2434,241 +2500,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - return { isValid: true }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); + } + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -2842,474 +3043,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index e269a2a3842..da913342795 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -1029,6 +1029,74 @@ jobs: } ] EOF + cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' + { + "create_discussion": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "category": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "upload_asset": { + "defaultMax": 10, + "fields": { + "path": { + "required": true, + "type": "string" + } + } + } + } + EOF cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF' const fs = require("fs"); const path = require("path"); @@ -3177,241 +3245,376 @@ jobs: } return { resolved: issueNumber, wasTemporaryId: false, errorMessage: null }; } - const maxBodyLength = 65000; - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; - case "link_sub_issue": - return 5; - default: - return 1; - } + const MAX_BODY_LENGTH = 65000; + const MAX_GITHUB_USERNAME_LENGTH = 39; + let cachedValidationConfig = null; + function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + cachedValidationConfig = {}; + return cachedValidationConfig; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + try { + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); } - return 0; + cachedValidationConfig = {}; + return cachedValidationConfig; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + } + function resetValidationConfigCache() { + cachedValidationConfig = null; + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + return 0; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; + } + function validateField(value, fieldName, validation, itemType, lineNum) { + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + if (value === undefined || value === null) { + return { isValid: true }; + } + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + if (validation.type === "string") { + if (typeof value !== "string") { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, + error: errorMsg, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "array") { + if (!Array.isArray(value)) { + if (validation.required) { return { isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, }; } return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, }; } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; + return { isValid: true, normalizedValue: value }; } - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { + if (validation.type === "boolean") { + if (typeof value !== "boolean") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, }; } - if (typeof value !== "number" && typeof value !== "string") { + return { isValid: true, normalizedValue: value }; + } + if (validation.type === "number") { + if (typeof value !== "number") { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, }; } - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { isValid: true, normalizedValue: value }; + } + return { isValid: true, normalizedValue: value }; + } + function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, }; } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { return { isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, }; } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } + } + if (customValidation === "parentAndSubDifferent") { + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { return { isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, }; } - return { isValid: true, normalizedValue: parsed }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + return null; + } + function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + if (!typeConfig) { + return { isValid: true, normalizedItem: item }; + } + const normalizedItem = { ...item }; + const errors = []; + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + } + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; + } + return { isValid: true, normalizedItem }; + } + function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; + } + function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; + } + function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); + } + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); + core.info(`Loaded validation config from ${validationConfigPath}`); } - return { isValid: true }; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { @@ -3585,474 +3788,30 @@ jobs: continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + Object.assign(item, validationResult.normalizedItem); + } else { + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index 118c96e521b..90621ac0417 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -198,6 +198,9 @@ var readBufferScript string //go:embed js/mcp_server_core.cjs var mcpServerCoreScript string +//go:embed js/safe_output_type_validator.cjs +var safeOutputTypeValidatorScript string + // GetJavaScriptSources returns a map of all embedded JavaScript sources // The keys are the relative paths from the js directory func GetJavaScriptSources() map[string]string { @@ -237,6 +240,7 @@ func GetJavaScriptSources() map[string]string { "update_runner.cjs": updateRunnerScript, "read_buffer.cjs": readBufferScript, "mcp_server_core.cjs": mcpServerCoreScript, + "safe_output_type_validator.cjs": safeOutputTypeValidatorScript, } } diff --git a/pkg/workflow/js/collect_ndjson_output.cjs b/pkg/workflow/js/collect_ndjson_output.cjs index b6d19f14ea8..8ce7d994935 100644 --- a/pkg/workflow/js/collect_ndjson_output.cjs +++ b/pkg/workflow/js/collect_ndjson_output.cjs @@ -4,72 +4,30 @@ async function main() { const fs = require("fs"); const { sanitizeContent } = require("./sanitize_content.cjs"); - const { isTemporaryId } = require("./temporary_id.cjs"); - const maxBodyLength = 65000; - // Maximum length for GitHub usernames (39 characters) - // Reference: https://github.com/dead-claudia/github-limits - const MAX_GITHUB_USERNAME_LENGTH = 39; - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create_issue": - return 1; - case "create_agent_task": - return 1; - case "add_comment": - return 1; - case "create_pull_request": - return 1; - case "create_pull_request_review_comment": - return 1; - case "add_labels": - return 5; - case "add_reviewer": - return 3; - case "assign_milestone": - return 1; - case "assign_to_agent": - return 1; - case "update_issue": - return 1; - case "update_pull_request": - return 1; - case "push_to_pull_request_branch": - return 1; - case "create_discussion": - return 1; - case "close_discussion": - return 1; - case "close_issue": - return 1; - case "close_pull_request": - return 1; - case "missing_tool": - return 20; - case "create_code_scanning_alert": - return 40; - case "upload_asset": - return 10; - case "update_release": - return 1; - case "noop": - return 1; // Default max for noop messages - case "link_sub_issue": - return 5; // Default max for link_sub_issue - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + const { + validateItem, + getMaxAllowedForType, + getMinRequiredForType, + hasValidationConfig, + MAX_BODY_LENGTH: maxBodyLength, + resetValidationConfigCache, + } = require("./safe_output_type_validator.cjs"); + + // Load validation config from file and set it in environment for the validator to read + const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || "/tmp/gh-aw/safeoutputs/validation.json"; + try { + if (fs.existsSync(validationConfigPath)) { + const validationConfigContent = fs.readFileSync(validationConfigPath, "utf8"); + process.env.GH_AW_VALIDATION_CONFIG = validationConfigContent; + resetValidationConfigCache(); // Reset cache so it reloads from new env var + core.info(`Loaded validation config from ${validationConfigPath}`); } - return 0; + } catch (error) { + core.warning( + `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` + ); } + function repairJson(jsonStr) { let repaired = jsonStr.trim(); const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; @@ -105,153 +63,7 @@ async function main() { repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); return repaired; } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_code_scanning_alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_code_scanning_alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create_pull_request_review_comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; - } - /** - * Validate a value that can be either a positive integer (issue number) or a temporary ID. - * @param {any} value - The value to validate - * @param {string} fieldName - Name of the field for error messages - * @param {number} lineNum - Line number for error messages - * @returns {{ isValid: boolean, normalizedValue?: number | string, isTemporary?: boolean, error?: string }} - */ - function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { - if (value === undefined || value === null) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - // Check if it's a temporary ID - if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; - } - // Try to parse as positive integer - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed, isTemporary: false }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create_pull_request_review_comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_pull_request_review_comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create_code_scanning_alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create_code_scanning_alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { if (inputSchema.required && (value === undefined || value === null)) { return { @@ -430,501 +242,36 @@ async function main() { continue; } core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create_issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - // Validate parent field if provided - if (item.parent !== undefined) { - const parentValidation = validateIssueOrPRNumber(item.parent, "create_issue 'parent'", i + 1); - if (!parentValidation.isValid) { - if (parentValidation.error) errors.push(parentValidation.error); - continue; - } - } - break; - case "add_comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - // Validate number - if (item.item_number !== undefined) { - const itemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_comment 'item_number'", i + 1); - if (!itemNumberValidation.isValid) { - if (itemNumberValidation.error) errors.push(itemNumberValidation.error); - continue; - } - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "create_pull_request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - item.branch = sanitizeContent(item.branch, 256); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label, 128) : label)); - } - break; - case "add_labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsItemNumberValidation = validateIssueOrPRNumber(item.item_number, "add_labels 'item_number'", i + 1); - if (!labelsItemNumberValidation.isValid) { - if (labelsItemNumberValidation.error) errors.push(labelsItemNumberValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label, 128)); - break; - case "add_reviewer": - if (!item.reviewers || !Array.isArray(item.reviewers)) { - errors.push(`Line ${i + 1}: add_reviewer requires a 'reviewers' array field`); - continue; - } - if (item.reviewers.some(reviewer => typeof reviewer !== "string")) { - errors.push(`Line ${i + 1}: add_reviewer reviewers array must contain only strings`); - continue; - } - const reviewerPRNumberValidation = validateIssueOrPRNumber(item.pull_request_number, "add_reviewer 'pull_request_number'", i + 1); - if (!reviewerPRNumberValidation.isValid) { - if (reviewerPRNumberValidation.error) errors.push(reviewerPRNumberValidation.error); - continue; - } - // Sanitize reviewer usernames (limit to MAX_GITHUB_USERNAME_LENGTH) - item.reviewers = item.reviewers.map(reviewer => sanitizeContent(reviewer, MAX_GITHUB_USERNAME_LENGTH)); - break; - case "update_issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 128); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update_issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "update_pull_request": - const hasValidPRField = item.title !== undefined || item.body !== undefined; - if (!hasValidPRField) { - errors.push(`Line ${i + 1}: update_pull_request requires at least one of: 'title' or 'body' fields`); - continue; - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title, 256); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_pull_request 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - } - // Validate operation field if provided - if (item.operation !== undefined) { - if (!["replace", "append", "prepend"].includes(item.operation)) { - errors.push(`Line ${i + 1}: update_pull_request 'operation' must be one of: 'replace', 'append', 'prepend'`); - continue; - } - } - const updatePRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "update_pull_request 'pull_request_number'", - i + 1 - ); - if (!updatePRNumValidation.isValid) { - if (updatePRNumValidation.error) errors.push(updatePRNumValidation.error); - continue; - } - break; - case "assign_milestone": - // Validate issue_number - const assignMilestoneIssueValidation = validateIssueOrPRNumber(item.issue_number, "assign_milestone 'issue_number'", i + 1); - if (!assignMilestoneIssueValidation.isValid) { - if (assignMilestoneIssueValidation.error) errors.push(assignMilestoneIssueValidation.error); - continue; - } - - // Validate milestone_number - const milestoneValidation = validatePositiveInteger(item.milestone_number, "assign_milestone 'milestone_number'", i + 1); - if (!milestoneValidation.isValid) { - if (milestoneValidation.error) errors.push(milestoneValidation.error); - continue; - } - break; - case "assign_to_agent": - // Validate issue_number (required) - const assignToAgentIssueValidation = validatePositiveInteger(item.issue_number, "assign_to_agent 'issue_number'", i + 1); - if (!assignToAgentIssueValidation.isValid) { - if (assignToAgentIssueValidation.error) errors.push(assignToAgentIssueValidation.error); - continue; - } - - // Validate agent (optional string) - if (item.agent !== undefined) { - if (typeof item.agent !== "string") { - errors.push(`Line ${i + 1}: assign_to_agent 'agent' must be a string`); - continue; - } - item.agent = sanitizeContent(item.agent, 128); - } - - break; - case "push_to_pull_request_branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch, 256); - item.message = sanitizeContent(item.message, maxBodyLength); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push_to_pull_request_branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create_pull_request_review_comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create_pull_request_review_comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request_review_comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create_pull_request_review_comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create_pull_request_review_comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create_discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category, 128); - } - item.title = sanitizeContent(item.title, 128); - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "close_discussion": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_discussion requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - - // Validate optional reason field - if (item.reason !== undefined) { - if (typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be a string`); - continue; - } - const allowedReasons = ["RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"]; - if (!allowedReasons.includes(item.reason.toUpperCase())) { - errors.push(`Line ${i + 1}: close_discussion 'reason' must be one of: ${allowedReasons.join(", ")}, got ${item.reason}`); - continue; - } - item.reason = item.reason.toUpperCase(); - } - - // Validate optional discussion_number field - const discussionNumberValidation = validateOptionalPositiveInteger( - item.discussion_number, - "close_discussion 'discussion_number'", - i + 1 - ); - if (!discussionNumberValidation.isValid) { - if (discussionNumberValidation.error) errors.push(discussionNumberValidation.error); - continue; - } - break; - case "close_issue": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_issue requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - - // Validate optional issue_number field - const issueNumberValidation = validateOptionalPositiveInteger(item.issue_number, "close_issue 'issue_number'", i + 1); - if (!issueNumberValidation.isValid) { - if (issueNumberValidation.error) errors.push(issueNumberValidation.error); - continue; - } - break; - case "close_pull_request": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: close_pull_request requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - // Validate optional pull_request_number field - const prNumberValidation = validateOptionalPositiveInteger( - item.pull_request_number, - "close_pull_request 'pull_request_number'", - i + 1 - ); - if (!prNumberValidation.isValid) { - if (prNumberValidation.error) errors.push(prNumberValidation.error); - continue; - } - break; - case "create_agent_task": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_agent_task requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "missing_tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool, 128); - item.reason = sanitizeContent(item.reason, 256); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing_tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives, 512); - } - break; - case "update_release": - // Validate tag (optional - will be inferred from context if missing) - if (item.tag !== undefined && typeof item.tag !== "string") { - errors.push(`Line ${i + 1}: update_release 'tag' must be a string if provided`); - continue; - } - // Validate operation - if (!item.operation || typeof item.operation !== "string") { - errors.push(`Line ${i + 1}: update_release requires an 'operation' string field`); - continue; - } - if (item.operation !== "replace" && item.operation !== "append" && item.operation !== "prepend") { - errors.push(`Line ${i + 1}: update_release 'operation' must be 'replace', 'append', or 'prepend'`); - continue; - } - // Validate body - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update_release requires a 'body' string field`); - continue; - } - // Sanitize content - if (item.tag) { - item.tag = sanitizeContent(item.tag, 256); - } - item.body = sanitizeContent(item.body, maxBodyLength); - break; - case "upload_asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "noop": - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: noop requires a 'message' string field`); - continue; - } - item.message = sanitizeContent(item.message, maxBodyLength); - break; - case "create_code_scanning_alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create_code_scanning_alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create_code_scanning_alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create_code_scanning_alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file, 512); - item.severity = sanitizeContent(item.severity, 64); - item.message = sanitizeContent(item.message, 2048); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix, 128); - } - break; - case "link_sub_issue": - // Validate parent_issue_number (required) - can be positive integer or temporary ID - const parentIssueValidation = validateIssueNumberOrTemporaryId( - item.parent_issue_number, - "link_sub_issue 'parent_issue_number'", - i + 1 - ); - if (!parentIssueValidation.isValid) { - if (parentIssueValidation.error) errors.push(parentIssueValidation.error); - continue; - } - // Validate sub_issue_number (required) - can be positive integer or temporary ID - const subIssueValidation = validateIssueNumberOrTemporaryId(item.sub_issue_number, "link_sub_issue 'sub_issue_number'", i + 1); - if (!subIssueValidation.isValid) { - if (subIssueValidation.error) errors.push(subIssueValidation.error); - continue; - } - // Ensure parent and sub are different issues - if (parentIssueValidation.normalizedValue === subIssueValidation.normalizedValue) { - errors.push(`Line ${i + 1}: link_sub_issue 'parent_issue_number' and 'sub_issue_number' must be different`); - continue; - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + // Use the validation engine to validate the item + if (hasValidationConfig(itemType)) { + const validationResult = validateItem(item, itemType, i + 1); + if (!validationResult.isValid) { + if (validationResult.error) { + errors.push(validationResult.error); + } + continue; + } + // Update item with normalized values + Object.assign(item, validationResult.normalizedItem); + } else { + // Fall back to validateItemWithSafeJobConfig for unknown types + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); continue; } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + Object.assign(item, validation.normalizedItem); + } } + core.info(`Line ${i + 1}: Valid ${itemType} item`); parsedItems.push(item); } catch (error) { diff --git a/pkg/workflow/js/collect_ndjson_output.test.cjs b/pkg/workflow/js/collect_ndjson_output.test.cjs index 621588e7a38..03f574bbd7b 100644 --- a/pkg/workflow/js/collect_ndjson_output.test.cjs +++ b/pkg/workflow/js/collect_ndjson_output.test.cjs @@ -72,6 +72,127 @@ describe("collect_ndjson_output.cjs", () => { // Make fs available globally for the evaluated script global.fs = fs; + + // Set up validation config file for the collect_ndjson_output script + const safeOutputsDir = "/tmp/gh-aw/safeoutputs"; + if (!fs.existsSync(safeOutputsDir)) { + fs.mkdirSync(safeOutputsDir, { recursive: true }); + } + + // Write validation config from safe_output_validation_config.go (Go source of truth) + // This is a minimal set that covers the types used in tests + const validationConfig = { + create_issue: { + defaultMax: 1, + fields: { + title: { required: true, type: "string", sanitize: true, maxLength: 128 }, + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + labels: { type: "array", itemType: "string", itemSanitize: true, itemMaxLength: 128 }, + parent: { issueOrPRNumber: true }, + temporary_id: { type: "string" }, + }, + }, + add_comment: { + defaultMax: 1, + fields: { + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + item_number: { issueOrPRNumber: true }, + }, + }, + create_pull_request: { + defaultMax: 1, + fields: { + title: { required: true, type: "string", sanitize: true, maxLength: 128 }, + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + branch: { required: true, type: "string", sanitize: true, maxLength: 256 }, + labels: { type: "array", itemType: "string", itemSanitize: true, itemMaxLength: 128 }, + }, + }, + update_issue: { + defaultMax: 1, + customValidation: "requiresOneOf:status,title,body", + fields: { + status: { type: "string", enum: ["open", "closed"] }, + title: { type: "string", sanitize: true, maxLength: 128 }, + body: { type: "string", sanitize: true, maxLength: 65000 }, + issue_number: { issueOrPRNumber: true }, + }, + }, + create_pull_request_review_comment: { + defaultMax: 1, + customValidation: "startLineLessOrEqualLine", + fields: { + path: { required: true, type: "string" }, + line: { required: true, positiveInteger: true }, + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + start_line: { optionalPositiveInteger: true }, + side: { type: "string", enum: ["LEFT", "RIGHT"] }, + }, + }, + link_sub_issue: { + defaultMax: 5, + customValidation: "parentAndSubDifferent", + fields: { + parent_issue_number: { required: true, issueNumberOrTemporaryId: true }, + sub_issue_number: { required: true, issueNumberOrTemporaryId: true }, + }, + }, + noop: { + defaultMax: 1, + fields: { + message: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + }, + }, + missing_tool: { + defaultMax: 20, + fields: { + tool: { required: true, type: "string", sanitize: true, maxLength: 128 }, + reason: { required: true, type: "string", sanitize: true, maxLength: 256 }, + alternatives: { type: "string", sanitize: true, maxLength: 512 }, + }, + }, + create_code_scanning_alert: { + defaultMax: 40, + fields: { + file: { required: true, type: "string", sanitize: true, maxLength: 512 }, + line: { required: true, positiveInteger: true }, + severity: { required: true, type: "string", enum: ["error", "warning", "info", "note"] }, + message: { required: true, type: "string", sanitize: true, maxLength: 2048 }, + column: { optionalPositiveInteger: true }, + ruleIdSuffix: { + type: "string", + pattern: "^[a-zA-Z0-9_-]+$", + patternError: "must contain only alphanumeric characters, hyphens, and underscores", + sanitize: true, + maxLength: 128, + }, + }, + }, + assign_to_agent: { + defaultMax: 1, + fields: { + issue_number: { required: true, positiveInteger: true }, + agent: { type: "string", sanitize: true, maxLength: 128 }, + }, + }, + create_discussion: { + defaultMax: 1, + fields: { + title: { required: true, type: "string", sanitize: true, maxLength: 128 }, + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + category: { type: "string", sanitize: true, maxLength: 128 }, + }, + }, + update_release: { + defaultMax: 1, + fields: { + tag: { type: "string", sanitize: true, maxLength: 256 }, + operation: { required: true, type: "string", enum: ["replace", "append", "prepend"] }, + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + }, + }, + }; + fs.writeFileSync(path.join(safeOutputsDir, "validation.json"), JSON.stringify(validationConfig)); }); afterEach(() => { @@ -218,8 +339,8 @@ describe("collect_ndjson_output.cjs", () => { // Since there are errors and no valid items, setFailed should be called expect(mockCore.setFailed).toHaveBeenCalledTimes(1); const failedMessage = mockCore.setFailed.mock.calls[0][0]; - expect(failedMessage).toContain("requires a 'body' string field"); - expect(failedMessage).toContain("requires a 'title' string field"); + expect(failedMessage).toContain("requires a 'body' field (string)"); + expect(failedMessage).toContain("requires a 'title' field (string)"); // setOutput should not be called because of early return const setOutputCalls = mockCore.setOutput.mock.calls; @@ -278,9 +399,9 @@ describe("collect_ndjson_output.cjs", () => { expect(parsedOutput.items[0].body).toBe("Test body"); expect(parsedOutput.items[0].branch).toBe("feature-branch"); expect(parsedOutput.errors).toHaveLength(3); // Three incomplete PRs should cause errors - expect(parsedOutput.errors[0]).toContain("requires a 'body' string field"); - expect(parsedOutput.errors[1]).toContain("requires a 'title' string field"); - expect(parsedOutput.errors[2]).toContain("requires a 'title' string field"); + expect(parsedOutput.errors[0]).toContain("requires a 'body' field (string)"); + expect(parsedOutput.errors[1]).toContain("requires a 'title' field (string)"); + expect(parsedOutput.errors[2]).toContain("requires a 'title' field (string)"); }); it("should handle invalid JSON lines", async () => { @@ -383,8 +504,8 @@ describe("collect_ndjson_output.cjs", () => { expect(parsedOutput.items[0].title).toBe("Valid Discussion"); expect(parsedOutput.items[0].body).toBe("Valid body"); expect(parsedOutput.errors).toHaveLength(2); - expect(parsedOutput.errors[0]).toContain("requires a 'body' string field"); - expect(parsedOutput.errors[1]).toContain("requires a 'title' string field"); + expect(parsedOutput.errors[0]).toContain("requires a 'body' field (string)"); + expect(parsedOutput.errors[1]).toContain("requires a 'title' field (string)"); }); it("should skip empty lines", async () => { @@ -439,9 +560,9 @@ describe("collect_ndjson_output.cjs", () => { expect(parsedOutput.items[0].line).toBe(10); expect(parsedOutput.items[0].body).toBeDefined(); expect(parsedOutput.errors).toHaveLength(4); // 4 invalid items - expect(parsedOutput.errors.some(e => e.includes("line' must be a positive integer"))).toBe(true); - expect(parsedOutput.errors.some(e => e.includes("requires a 'line' number"))).toBe(true); - expect(parsedOutput.errors.some(e => e.includes("requires a 'path' string"))).toBe(true); + expect(parsedOutput.errors.some(e => e.includes("line' must be a valid positive integer"))).toBe(true); + expect(parsedOutput.errors.some(e => e.includes("'line' is required"))).toBe(true); + expect(parsedOutput.errors.some(e => e.includes("requires a 'path' field (string)"))).toBe(true); expect(parsedOutput.errors.some(e => e.includes("start_line' must be less than or equal to 'line'"))).toBe(true); }); @@ -502,9 +623,9 @@ describe("collect_ndjson_output.cjs", () => { expect(parsedOutput.items[2].operation).toBe("replace"); expect(parsedOutput.items[0].body).toBeDefined(); expect(parsedOutput.errors).toHaveLength(3); // 3 invalid items - expect(parsedOutput.errors.some(e => e.includes("operation' must be 'replace', 'append', or 'prepend'"))).toBe(true); - expect(parsedOutput.errors.some(e => e.includes("requires an 'operation' string field"))).toBe(true); - expect(parsedOutput.errors.some(e => e.includes("requires a 'body' string field"))).toBe(true); + expect(parsedOutput.errors.some(e => e.includes("operation' must be one of:"))).toBe(true); + expect(parsedOutput.errors.some(e => e.includes("requires a 'operation' field (string)"))).toBe(true); + expect(parsedOutput.errors.some(e => e.includes("requires a 'body' field (string)"))).toBe(true); }); it("should respect max limits for create-pull-request-review-comment from config", async () => { @@ -1634,10 +1755,10 @@ Line 3"} expect(parsedOutput.items[2]).toEqual({ type: "create_code_scanning_alert", file: "src/complete.js", - line: "30", + line: 30, // String "30" is normalized to integer 30 severity: "note", // Should be normalized to lowercase message: "Complete example", - column: "5", + column: 5, // String "5" is normalized to integer 5 ruleIdSuffix: "complete-rule", }); }); @@ -1662,7 +1783,7 @@ Line 3"} expect(mockCore.setFailed).toHaveBeenCalledTimes(1); const failedMessage = mockCore.setFailed.mock.calls[0][0]; expect(failedMessage).toContain("create_code_scanning_alert requires a 'file' field (string)"); - expect(failedMessage).toContain("create_code_scanning_alert requires a 'line' field (number or string)"); + expect(failedMessage).toContain("create_code_scanning_alert 'line' is required"); expect(failedMessage).toContain("create_code_scanning_alert requires a 'severity' field (string)"); expect(failedMessage).toContain("create_code_scanning_alert requires a 'message' field (string)"); @@ -1692,7 +1813,7 @@ Line 3"} expect(mockCore.setFailed).toHaveBeenCalledTimes(1); const failedMessage = mockCore.setFailed.mock.calls[0][0]; expect(failedMessage).toContain("create_code_scanning_alert requires a 'file' field (string)"); - expect(failedMessage).toContain("create_code_scanning_alert requires a 'line' field (number or string)"); + expect(failedMessage).toContain("create_code_scanning_alert 'line' is required"); expect(failedMessage).toContain("create_code_scanning_alert requires a 'severity' field (string)"); expect(failedMessage).toContain("create_code_scanning_alert requires a 'message' field (string)"); @@ -1782,7 +1903,7 @@ Line 3"} expect(parsedOutput.items[0].file).toBe("src/valid.js"); expect(parsedOutput.items[1].file).toBe("src/valid2.js"); - expect(parsedOutput.errors).toContain("Line 2: create_code_scanning_alert requires a 'line' field (number or string)"); + expect(parsedOutput.errors).toContain("Line 2: create_code_scanning_alert 'line' is required"); }); it("should reject code scanning alert entries with invalid line and column values", async () => { @@ -2376,7 +2497,7 @@ Line 3"} // When there are only errors and no valid items, setFailed is called instead of setOutput expect(mockCore.setFailed).toHaveBeenCalled(); const failedCall = mockCore.setFailed.mock.calls[0][0]; - expect(failedCall).toContain("noop requires a 'message' string field"); + expect(failedCall).toContain("noop requires a 'message' field (string)"); }); it("should reject noop with non-string message", async () => { @@ -2397,7 +2518,7 @@ Line 3"} // When there are only errors and no valid items, setFailed is called instead of setOutput expect(mockCore.setFailed).toHaveBeenCalled(); const failedCall = mockCore.setFailed.mock.calls[0][0]; - expect(failedCall).toContain("noop requires a 'message' string field"); + expect(failedCall).toContain("noop requires a 'message' field (string)"); }); it("should sanitize noop message content", async () => { diff --git a/pkg/workflow/js/safe_output_type_validator.cjs b/pkg/workflow/js/safe_output_type_validator.cjs new file mode 100644 index 00000000000..1f7587c0bae --- /dev/null +++ b/pkg/workflow/js/safe_output_type_validator.cjs @@ -0,0 +1,553 @@ +// @ts-check +/// + +/** + * Safe Output Type Validator + * + * A data-driven validation engine for safe output types. + * Validation rules are loaded from GH_AW_VALIDATION_CONFIG environment variable, + * which is generated by the Go compiler from the single source of truth. + */ + +const { sanitizeContent } = require("./sanitize_content.cjs"); +const { isTemporaryId } = require("./temporary_id.cjs"); + +/** + * Default max body length for GitHub content + */ +const MAX_BODY_LENGTH = 65000; + +/** + * Maximum length for GitHub usernames + * Reference: https://github.com/dead-claudia/github-limits + */ +const MAX_GITHUB_USERNAME_LENGTH = 39; + +/** + * @typedef {Object} FieldValidation + * @property {boolean} [required] - Whether the field is required + * @property {string} [type] - Expected type: 'string', 'number', 'boolean', 'array' + * @property {boolean} [sanitize] - Whether to sanitize string content + * @property {number} [maxLength] - Maximum length for strings + * @property {boolean} [positiveInteger] - Must be a positive integer + * @property {boolean} [optionalPositiveInteger] - Optional but if present must be positive integer + * @property {boolean} [issueOrPRNumber] - Can be issue/PR number or undefined + * @property {boolean} [issueNumberOrTemporaryId] - Can be issue number or temporary ID + * @property {string[]} [enum] - Allowed values for the field + * @property {string} [itemType] - For arrays, the type of items + * @property {boolean} [itemSanitize] - For arrays, whether to sanitize items + * @property {number} [itemMaxLength] - For arrays, max length per item + * @property {string} [pattern] - Regex pattern the value must match + * @property {string} [patternError] - Error message for pattern mismatch + */ + +/** + * @typedef {Object} TypeValidationConfig + * @property {number} defaultMax - Default max count for this type + * @property {Object.} fields - Field validation rules + * @property {string} [customValidation] - Custom validation rule identifier + */ + +/** @type {Object.|null} */ +let cachedValidationConfig = null; + +/** + * Load validation configuration from environment variable + * @returns {Object.} + */ +function loadValidationConfig() { + if (cachedValidationConfig !== null) { + return cachedValidationConfig; + } + + const configJson = process.env.GH_AW_VALIDATION_CONFIG; + if (!configJson) { + // Return empty config if not provided - validation will be skipped + cachedValidationConfig = {}; + return cachedValidationConfig; + } + + try { + /** @type {Object.} */ + const parsed = JSON.parse(configJson); + cachedValidationConfig = parsed || {}; + return cachedValidationConfig; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + // Log as error since missing validation config is critical + if (typeof core !== "undefined") { + core.error(`CRITICAL: Failed to parse validation config: ${errorMsg}. Validation will be skipped.`); + } + cachedValidationConfig = {}; + return cachedValidationConfig; + } +} + +/** + * Reset the cached validation config (for testing) + */ +function resetValidationConfigCache() { + cachedValidationConfig = null; +} + +/** + * Get the default max count for a type + * @param {string} itemType - The safe output type + * @param {Object} [config] - Configuration override from safe-outputs config + * @returns {number} The max allowed count + */ +function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + return typeConfig?.defaultMax ?? 1; +} + +/** + * Get the minimum required count for a type + * @param {string} itemType - The safe output type + * @param {Object} [config] - Configuration from safe-outputs config + * @returns {number} The minimum required count + */ +function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; +} + +/** + * Validate a positive integer field + * @param {any} value - Value to validate + * @param {string} fieldName - Field name for error messages + * @param {number} lineNum - Line number for error messages + * @returns {{isValid: boolean, normalizedValue?: number, error?: string}} + */ +function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; +} + +/** + * Validate an optional positive integer field + * @param {any} value - Value to validate + * @param {string} fieldName - Field name for error messages + * @param {number} lineNum - Line number for error messages + * @returns {{isValid: boolean, normalizedValue?: number, error?: string}} + */ +function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a valid positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; +} + +/** + * Validate an issue/PR number field (optional, accepts number or string) + * @param {any} value - Value to validate + * @param {string} fieldName - Field name for error messages + * @param {number} lineNum - Line number for error messages + * @returns {{isValid: boolean, error?: string}} + */ +function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; +} + +/** + * Validate a value that can be either a positive integer (issue number) or a temporary ID. + * @param {any} value - The value to validate + * @param {string} fieldName - Name of the field for error messages + * @param {number} lineNum - Line number for error messages + * @returns {{isValid: boolean, normalizedValue?: number|string, isTemporary?: boolean, error?: string}} + */ +function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { + if (value === undefined || value === null) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + // Check if it's a temporary ID + if (isTemporaryId(value)) { + return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + } + // Try to parse as positive integer + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer or temporary ID (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed, isTemporary: false }; +} + +/** + * Validate a single field based on its validation configuration + * @param {any} value - The field value + * @param {string} fieldName - The field name + * @param {FieldValidation} validation - The validation configuration + * @param {string} itemType - The item type for error messages + * @param {number} lineNum - Line number for error messages + * @returns {{isValid: boolean, normalizedValue?: any, error?: string}} + */ +function validateField(value, fieldName, validation, itemType, lineNum) { + // For positiveInteger fields, delegate required check to validatePositiveInteger + if (validation.positiveInteger) { + return validatePositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + + // For issueNumberOrTemporaryId fields, delegate required check to validateIssueNumberOrTemporaryId + if (validation.issueNumberOrTemporaryId) { + return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum); + } + + // Handle required check for other fields + if (validation.required && (value === undefined || value === null)) { + const fieldType = validation.type || "string"; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, + }; + } + + // If not required and not present, skip other validations + if (value === undefined || value === null) { + return { isValid: true }; + } + + // Handle optionalPositiveInteger validation + if (validation.optionalPositiveInteger) { + return validateOptionalPositiveInteger(value, `${itemType} '${fieldName}'`, lineNum); + } + + // Handle issueOrPRNumber validation + if (validation.issueOrPRNumber) { + return validateIssueOrPRNumber(value, `${itemType} '${fieldName}'`, lineNum); + } + + // Handle type validation + if (validation.type === "string") { + if (typeof value !== "string") { + // For required fields, use "requires a" format for both missing and wrong type + if (validation.required) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a string`, + }; + } + + // Handle pattern validation + if (validation.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value.trim())) { + const errorMsg = validation.patternError || `must match pattern ${validation.pattern}`; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' ${errorMsg}`, + }; + } + } + + // Handle enum validation + if (validation.enum) { + const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); + if (!normalizedEnum.includes(normalizedValue)) { + // Use special format for 2-option enums: "'field' must be 'A' or 'B'" + // Use standard format for more options: "'field' must be one of: A, B, C" + let errorMsg; + if (validation.enum.length === 2) { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be '${validation.enum[0]}' or '${validation.enum[1]}'`; + } else { + errorMsg = `Line ${lineNum}: ${itemType} '${fieldName}' must be one of: ${validation.enum.join(", ")}`; + } + return { + isValid: false, + error: errorMsg, + }; + } + // Return the properly cased enum value if there's a case difference + const matchIndex = normalizedEnum.indexOf(normalizedValue); + let normalizedResult = validation.enum[matchIndex]; + // Apply sanitization if configured + if (validation.sanitize && validation.maxLength) { + normalizedResult = sanitizeContent(normalizedResult, validation.maxLength); + } + return { isValid: true, normalizedValue: normalizedResult }; + } + + // Handle sanitization + if (validation.sanitize) { + const sanitized = sanitizeContent(value, validation.maxLength || MAX_BODY_LENGTH); + return { isValid: true, normalizedValue: sanitized }; + } + + return { isValid: true, normalizedValue: value }; + } + + if (validation.type === "array") { + if (!Array.isArray(value)) { + // For required fields, use "requires a" format for both missing and wrong type + if (validation.required) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (array)`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be an array`, + }; + } + + // Validate array items + if (validation.itemType === "string") { + const hasInvalidItem = value.some(item => typeof item !== "string"); + if (hasInvalidItem) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} ${fieldName} array must contain only strings`, + }; + } + + // Sanitize items if configured + if (validation.itemSanitize) { + const sanitizedItems = value.map(item => + typeof item === "string" ? sanitizeContent(item, validation.itemMaxLength || 128) : item + ); + return { isValid: true, normalizedValue: sanitizedItems }; + } + } + + return { isValid: true, normalizedValue: value }; + } + + if (validation.type === "boolean") { + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a boolean`, + }; + } + return { isValid: true, normalizedValue: value }; + } + + if (validation.type === "number") { + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`, + }; + } + return { isValid: true, normalizedValue: value }; + } + + // No specific type validation, return as-is + return { isValid: true, normalizedValue: value }; +} + +/** + * Execute custom validation rules + * @param {Object} item - The item to validate + * @param {string} customValidation - The custom validation rule identifier + * @param {number} lineNum - Line number for error messages + * @param {string} itemType - The item type for error messages + * @returns {{isValid: boolean, error?: string}|null} + */ +function executeCustomValidation(item, customValidation, lineNum, itemType) { + if (!customValidation) { + return null; + } + + // Parse custom validation rule + if (customValidation.startsWith("requiresOneOf:")) { + const fields = customValidation.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined); + if (!hasValidField) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, + }; + } + } + + if (customValidation === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, + }; + } + } + } + + if (customValidation === "parentAndSubDifferent") { + // Normalize values for comparison + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, + }; + } + } + + return null; +} + +/** + * Validate a safe output item against its type configuration + * @param {Object} item - The item to validate + * @param {string} itemType - The item type (e.g., "create_issue") + * @param {number} lineNum - Line number for error messages + * @returns {{isValid: boolean, normalizedItem?: Object, error?: string}} + */ +function validateItem(item, itemType, lineNum) { + const validationConfig = loadValidationConfig(); + const typeConfig = validationConfig[itemType]; + + if (!typeConfig) { + // Unknown type - let the caller handle this + return { isValid: true, normalizedItem: item }; + } + + const normalizedItem = { ...item }; + const errors = []; + + // Run custom validation first if defined + if (typeConfig.customValidation) { + const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + if (customResult && !customResult.isValid) { + return customResult; + } + } + + // Validate each configured field + for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { + const fieldValue = item[fieldName]; + const result = validateField(fieldValue, fieldName, validation, itemType, lineNum); + + if (!result.isValid) { + errors.push(result.error); + } else if (result.normalizedValue !== undefined) { + normalizedItem[fieldName] = result.normalizedValue; + } + } + + if (errors.length > 0) { + return { isValid: false, error: errors[0] }; // Return first error + } + + return { isValid: true, normalizedItem }; +} + +/** + * Check if a type has validation configuration + * @param {string} itemType - The item type + * @returns {boolean} + */ +function hasValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return itemType in validationConfig; +} + +/** + * Get the validation configuration for a type + * @param {string} itemType - The item type + * @returns {TypeValidationConfig|undefined} + */ +function getValidationConfig(itemType) { + const validationConfig = loadValidationConfig(); + return validationConfig[itemType]; +} + +/** + * Get all known safe output types + * @returns {string[]} + */ +function getKnownTypes() { + const validationConfig = loadValidationConfig(); + return Object.keys(validationConfig); +} + +module.exports = { + // Main validation functions + validateItem, + validateField, + validatePositiveInteger, + validateOptionalPositiveInteger, + validateIssueOrPRNumber, + validateIssueNumberOrTemporaryId, + + // Configuration accessors + loadValidationConfig, + resetValidationConfigCache, + getMaxAllowedForType, + getMinRequiredForType, + hasValidationConfig, + getValidationConfig, + getKnownTypes, + + // Constants + MAX_BODY_LENGTH, + MAX_GITHUB_USERNAME_LENGTH, +}; diff --git a/pkg/workflow/js/safe_output_type_validator.test.cjs b/pkg/workflow/js/safe_output_type_validator.test.cjs new file mode 100644 index 00000000000..f96c9166852 --- /dev/null +++ b/pkg/workflow/js/safe_output_type_validator.test.cjs @@ -0,0 +1,480 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock core global +const mockCore = { + warning: vi.fn(), + info: vi.fn(), + error: vi.fn(), +}; +global.core = mockCore; + +// Sample validation config to set in environment +const SAMPLE_VALIDATION_CONFIG = { + create_issue: { + defaultMax: 1, + fields: { + title: { required: true, type: "string", sanitize: true, maxLength: 128 }, + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + labels: { type: "array", itemType: "string", itemSanitize: true, itemMaxLength: 128 }, + parent: { issueOrPRNumber: true }, + temporary_id: { type: "string" }, + }, + }, + add_comment: { + defaultMax: 1, + fields: { + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + item_number: { issueOrPRNumber: true }, + }, + }, + create_pull_request: { + defaultMax: 1, + fields: { + title: { required: true, type: "string", sanitize: true, maxLength: 128 }, + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + branch: { required: true, type: "string", sanitize: true, maxLength: 256 }, + labels: { type: "array", itemType: "string", itemSanitize: true, itemMaxLength: 128 }, + }, + }, + update_issue: { + defaultMax: 1, + customValidation: "requiresOneOf:status,title,body", + fields: { + status: { type: "string", enum: ["open", "closed"] }, + title: { type: "string", sanitize: true, maxLength: 128 }, + body: { type: "string", sanitize: true, maxLength: 65000 }, + issue_number: { issueOrPRNumber: true }, + }, + }, + create_pull_request_review_comment: { + defaultMax: 1, + customValidation: "startLineLessOrEqualLine", + fields: { + path: { required: true, type: "string" }, + line: { required: true, positiveInteger: true }, + body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + start_line: { optionalPositiveInteger: true }, + side: { type: "string", enum: ["LEFT", "RIGHT"] }, + }, + }, + link_sub_issue: { + defaultMax: 5, + customValidation: "parentAndSubDifferent", + fields: { + parent_issue_number: { required: true, issueNumberOrTemporaryId: true }, + sub_issue_number: { required: true, issueNumberOrTemporaryId: true }, + }, + }, + noop: { + defaultMax: 1, + fields: { + message: { required: true, type: "string", sanitize: true, maxLength: 65000 }, + }, + }, + missing_tool: { + defaultMax: 20, + fields: { + tool: { required: true, type: "string", sanitize: true, maxLength: 128 }, + reason: { required: true, type: "string", sanitize: true, maxLength: 256 }, + alternatives: { type: "string", sanitize: true, maxLength: 512 }, + }, + }, + create_code_scanning_alert: { + defaultMax: 40, + fields: { + file: { required: true, type: "string", sanitize: true, maxLength: 512 }, + line: { required: true, positiveInteger: true }, + severity: { required: true, type: "string", enum: ["error", "warning", "info", "note"] }, + message: { required: true, type: "string", sanitize: true, maxLength: 2048 }, + column: { optionalPositiveInteger: true }, + ruleIdSuffix: { + type: "string", + pattern: "^[a-zA-Z0-9_-]+$", + patternError: "must contain only alphanumeric characters, hyphens, and underscores", + sanitize: true, + maxLength: 128, + }, + }, + }, +}; + +describe("safe_output_type_validator", () => { + beforeEach(async () => { + vi.clearAllMocks(); + // Reset the validation config cache before each test + const { resetValidationConfigCache } = await import("./safe_output_type_validator.cjs"); + resetValidationConfigCache(); + // Set the validation config in environment + process.env.GH_AW_VALIDATION_CONFIG = JSON.stringify(SAMPLE_VALIDATION_CONFIG); + }); + + describe("loadValidationConfig", () => { + it("should load config from environment variable", async () => { + const { loadValidationConfig } = await import("./safe_output_type_validator.cjs"); + + const config = loadValidationConfig(); + + expect(config).toBeDefined(); + expect(config.create_issue).toBeDefined(); + expect(config.create_issue.defaultMax).toBe(1); + }); + + it("should return empty config when env var is not set", async () => { + delete process.env.GH_AW_VALIDATION_CONFIG; + const { loadValidationConfig, resetValidationConfigCache } = await import("./safe_output_type_validator.cjs"); + resetValidationConfigCache(); + + const config = loadValidationConfig(); + + expect(config).toEqual({}); + }); + + it("should return empty config on invalid JSON", async () => { + process.env.GH_AW_VALIDATION_CONFIG = "invalid json"; + const { loadValidationConfig, resetValidationConfigCache } = await import("./safe_output_type_validator.cjs"); + resetValidationConfigCache(); + + const config = loadValidationConfig(); + + expect(config).toEqual({}); + expect(mockCore.error).toHaveBeenCalled(); + }); + }); + + describe("validateItem", () => { + it("should validate create_issue with all required fields", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "create_issue", title: "Test Issue", body: "Test body" }, "create_issue", 1); + + expect(result.isValid).toBe(true); + expect(result.normalizedItem).toBeDefined(); + }); + + it("should fail validation when required title is missing", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "create_issue", body: "Test body" }, "create_issue", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("title"); + }); + + it("should fail validation when required body is missing", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "create_issue", title: "Test title" }, "create_issue", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("body"); + }); + + it("should sanitize string fields", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "create_issue", title: "Test @mention Issue", body: "Test body" }, "create_issue", 1); + + expect(result.isValid).toBe(true); + // The sanitizeContent function converts @mentions to backticked format + expect(result.normalizedItem.title).toContain("`@mention`"); + }); + }); + + describe("validatePositiveInteger", () => { + it("should validate positive integer", async () => { + const { validatePositiveInteger } = await import("./safe_output_type_validator.cjs"); + + const result = validatePositiveInteger(42, "line", 1); + + expect(result.isValid).toBe(true); + expect(result.normalizedValue).toBe(42); + }); + + it("should reject negative numbers", async () => { + const { validatePositiveInteger } = await import("./safe_output_type_validator.cjs"); + + const result = validatePositiveInteger(-1, "line", 1); + + expect(result.isValid).toBe(false); + }); + + it("should reject zero", async () => { + const { validatePositiveInteger } = await import("./safe_output_type_validator.cjs"); + + const result = validatePositiveInteger(0, "line", 1); + + expect(result.isValid).toBe(false); + }); + + it("should parse string numbers", async () => { + const { validatePositiveInteger } = await import("./safe_output_type_validator.cjs"); + + const result = validatePositiveInteger("42", "line", 1); + + expect(result.isValid).toBe(true); + expect(result.normalizedValue).toBe(42); + }); + }); + + describe("validateOptionalPositiveInteger", () => { + it("should accept undefined", async () => { + const { validateOptionalPositiveInteger } = await import("./safe_output_type_validator.cjs"); + + const result = validateOptionalPositiveInteger(undefined, "column", 1); + + expect(result.isValid).toBe(true); + }); + + it("should validate positive integer when provided", async () => { + const { validateOptionalPositiveInteger } = await import("./safe_output_type_validator.cjs"); + + const result = validateOptionalPositiveInteger(5, "column", 1); + + expect(result.isValid).toBe(true); + expect(result.normalizedValue).toBe(5); + }); + + it("should reject zero when provided", async () => { + const { validateOptionalPositiveInteger } = await import("./safe_output_type_validator.cjs"); + + const result = validateOptionalPositiveInteger(0, "column", 1); + + expect(result.isValid).toBe(false); + }); + }); + + describe("validateIssueOrPRNumber", () => { + it("should accept undefined", async () => { + const { validateIssueOrPRNumber } = await import("./safe_output_type_validator.cjs"); + + const result = validateIssueOrPRNumber(undefined, "item_number", 1); + + expect(result.isValid).toBe(true); + }); + + it("should accept number", async () => { + const { validateIssueOrPRNumber } = await import("./safe_output_type_validator.cjs"); + + const result = validateIssueOrPRNumber(123, "item_number", 1); + + expect(result.isValid).toBe(true); + }); + + it("should accept string", async () => { + const { validateIssueOrPRNumber } = await import("./safe_output_type_validator.cjs"); + + const result = validateIssueOrPRNumber("456", "item_number", 1); + + expect(result.isValid).toBe(true); + }); + }); + + describe("validateIssueNumberOrTemporaryId", () => { + it("should accept positive integer", async () => { + const { validateIssueNumberOrTemporaryId } = await import("./safe_output_type_validator.cjs"); + + const result = validateIssueNumberOrTemporaryId(123, "issue_number", 1); + + expect(result.isValid).toBe(true); + expect(result.normalizedValue).toBe(123); + expect(result.isTemporary).toBe(false); + }); + + it("should accept temporary ID", async () => { + const { validateIssueNumberOrTemporaryId } = await import("./safe_output_type_validator.cjs"); + + const result = validateIssueNumberOrTemporaryId("aw_abc123def456", "issue_number", 1); + + expect(result.isValid).toBe(true); + expect(result.isTemporary).toBe(true); + expect(result.normalizedValue).toBe("aw_abc123def456"); + }); + + it("should reject invalid values", async () => { + const { validateIssueNumberOrTemporaryId } = await import("./safe_output_type_validator.cjs"); + + const result = validateIssueNumberOrTemporaryId(-1, "issue_number", 1); + + expect(result.isValid).toBe(false); + }); + }); + + describe("getMaxAllowedForType", () => { + it("should return defaultMax from config", async () => { + const { getMaxAllowedForType } = await import("./safe_output_type_validator.cjs"); + + const max = getMaxAllowedForType("create_issue"); + + expect(max).toBe(1); + }); + + it("should return overridden max from config", async () => { + const { getMaxAllowedForType } = await import("./safe_output_type_validator.cjs"); + + const max = getMaxAllowedForType("create_issue", { create_issue: { max: 5 } }); + + expect(max).toBe(5); + }); + + it("should return 1 for unknown type", async () => { + const { getMaxAllowedForType } = await import("./safe_output_type_validator.cjs"); + + const max = getMaxAllowedForType("unknown_type"); + + expect(max).toBe(1); + }); + }); + + describe("hasValidationConfig", () => { + it("should return true for known type", async () => { + const { hasValidationConfig } = await import("./safe_output_type_validator.cjs"); + + expect(hasValidationConfig("create_issue")).toBe(true); + }); + + it("should return false for unknown type", async () => { + const { hasValidationConfig } = await import("./safe_output_type_validator.cjs"); + + expect(hasValidationConfig("unknown_type")).toBe(false); + }); + }); + + describe("custom validation: requiresOneOf", () => { + it("should pass when at least one field is present", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "update_issue", status: "open" }, "update_issue", 1); + + expect(result.isValid).toBe(true); + }); + + it("should fail when none of the required fields are present", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "update_issue" }, "update_issue", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("requires at least one of"); + }); + }); + + describe("custom validation: startLineLessOrEqualLine", () => { + it("should pass when start_line <= line", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { type: "create_pull_request_review_comment", path: "test.js", line: 10, start_line: 5, body: "Test comment" }, + "create_pull_request_review_comment", + 1 + ); + + expect(result.isValid).toBe(true); + }); + + it("should fail when start_line > line", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { type: "create_pull_request_review_comment", path: "test.js", line: 5, start_line: 10, body: "Test comment" }, + "create_pull_request_review_comment", + 1 + ); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("start_line"); + }); + }); + + describe("custom validation: parentAndSubDifferent", () => { + it("should pass when parent and sub are different", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "link_sub_issue", parent_issue_number: 1, sub_issue_number: 2 }, "link_sub_issue", 1); + + expect(result.isValid).toBe(true); + }); + + it("should fail when parent and sub are the same", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "link_sub_issue", parent_issue_number: 1, sub_issue_number: 1 }, "link_sub_issue", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("must be different"); + }); + }); + + describe("enum validation", () => { + it("should validate enum values (case-insensitive)", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "update_issue", status: "OPEN" }, "update_issue", 1); + + expect(result.isValid).toBe(true); + expect(result.normalizedItem.status).toBe("open"); + }); + + it("should reject invalid enum value", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "update_issue", status: "invalid" }, "update_issue", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("must be 'open' or 'closed'"); + }); + }); + + describe("pattern validation", () => { + it("should validate pattern match", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { + type: "create_code_scanning_alert", + file: "test.js", + line: 10, + severity: "warning", + message: "Test", + ruleIdSuffix: "test-rule-123", + }, + "create_code_scanning_alert", + 1 + ); + + expect(result.isValid).toBe(true); + }); + + it("should reject pattern mismatch with custom error", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { type: "create_code_scanning_alert", file: "test.js", line: 10, severity: "warning", message: "Test", ruleIdSuffix: "test rule!" }, + "create_code_scanning_alert", + 1 + ); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("must contain only alphanumeric characters, hyphens, and underscores"); + }); + }); + + describe("array validation", () => { + it("should validate array of strings", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "create_issue", title: "Test", body: "Body", labels: ["bug", "enhancement"] }, "create_issue", 1); + + expect(result.isValid).toBe(true); + expect(Array.isArray(result.normalizedItem.labels)).toBe(true); + }); + + it("should reject array with non-string items", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "create_issue", title: "Test", body: "Body", labels: ["bug", 123] }, "create_issue", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("must contain only strings"); + }); + }); +}); diff --git a/pkg/workflow/mcp_servers.go b/pkg/workflow/mcp_servers.go index dad841de150..4842a6437cd 100644 --- a/pkg/workflow/mcp_servers.go +++ b/pkg/workflow/mcp_servers.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "fmt" "sort" "strings" @@ -142,6 +143,31 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any, } yaml.WriteString(" EOF\n") + // Generate and write the validation configuration from Go source of truth + // Only include validation for activated safe output types to keep validation.json small + var enabledTypes []string + if safeOutputConfig != "" { + var configMap map[string]any + if err := json.Unmarshal([]byte(safeOutputConfig), &configMap); err == nil { + for typeName := range configMap { + enabledTypes = append(enabledTypes, typeName) + } + } + } + validationConfigJSON, err := GetValidationConfigJSON(enabledTypes) + if err != nil { + // Log error prominently - validation config is critical for safe output processing + // The error will be caught at compile time if this ever fails + mcpServersLog.Printf("CRITICAL: Error generating validation config JSON: %v - validation will not work correctly", err) + validationConfigJSON = "{}" + } + yaml.WriteString(" cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF'\n") + // Write each line of the indented JSON with proper YAML indentation + for _, line := range strings.Split(validationConfigJSON, "\n") { + yaml.WriteString(" " + line + "\n") + } + yaml.WriteString(" EOF\n") + yaml.WriteString(" cat > /tmp/gh-aw/safeoutputs/mcp-server.cjs << 'EOF'\n") // Embed the safe-outputs MCP server script for _, line := range FormatJavaScriptForYAML(GetSafeOutputsMCPServerScript()) { diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index d377217b7be..07fcbff6c7e 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -756,8 +756,8 @@ since it's not supported by actions/github-script. lockContentStr := string(lockContent) - // Verify that push_to_pull_request_branch job is generated - if !strings.Contains(lockContentStr, "push_to_pull_request_branch:") { + // Verify that push_to_pull_request_branch job is generated (at proper YAML indentation) + if !strings.Contains(lockContentStr, "\n push_to_pull_request_branch:") { t.Errorf("Generated workflow should contain push_to_pull_request_branch job") } @@ -767,17 +767,26 @@ since it's not supported by actions/github-script. } // Extract the push_to_pull_request_branch job section - jobStartIdx := strings.Index(lockContentStr, " push_to_pull_request_branch:") + // Use newline prefix to ensure we find the YAML job definition, not JavaScript object properties + jobStartIdx := strings.Index(lockContentStr, "\n push_to_pull_request_branch:") if jobStartIdx == -1 { t.Fatal("Could not find push_to_pull_request_branch job section") } - - // Find the next job (or end of file) to get the job boundary - jobEndIdx := strings.Index(lockContentStr[jobStartIdx+1:], "\njobs:") - if jobEndIdx == -1 { - jobEndIdx = len(lockContentStr) - } else { - jobEndIdx = jobStartIdx + 1 + jobEndIdx + jobStartIdx++ // Skip the leading newline + + // Find the next job by looking for a line starting with exactly 2 spaces followed by a word and colon + // This avoids matching JavaScript object properties which have more indentation + jobEndIdx := len(lockContentStr) + remaining := lockContentStr[jobStartIdx+30:] // Skip past " push_to_pull_request_branch:" + lines := strings.Split(remaining, "\n") + offset := jobStartIdx + 30 + for _, line := range lines { + offset += len(line) + 1 // +1 for newline + // Match lines that look like YAML job definitions: exactly 2 spaces, then word characters, then colon + if len(line) > 2 && line[0] == ' ' && line[1] == ' ' && line[2] != ' ' && strings.Contains(line, ":") { + jobEndIdx = offset - len(line) - 1 + break + } } jobSection := lockContentStr[jobStartIdx:jobEndIdx] diff --git a/pkg/workflow/safe_output_validation_config.go b/pkg/workflow/safe_output_validation_config.go new file mode 100644 index 00000000000..30365c2f97e --- /dev/null +++ b/pkg/workflow/safe_output_validation_config.go @@ -0,0 +1,259 @@ +package workflow + +import ( + "encoding/json" +) + +// FieldValidation defines validation rules for a single field +type FieldValidation struct { + Required bool `json:"required,omitempty"` + Type string `json:"type,omitempty"` + Sanitize bool `json:"sanitize,omitempty"` + MaxLength int `json:"maxLength,omitempty"` + PositiveInteger bool `json:"positiveInteger,omitempty"` + OptionalPositiveInteger bool `json:"optionalPositiveInteger,omitempty"` + IssueOrPRNumber bool `json:"issueOrPRNumber,omitempty"` + IssueNumberOrTemporaryID bool `json:"issueNumberOrTemporaryId,omitempty"` + Enum []string `json:"enum,omitempty"` + ItemType string `json:"itemType,omitempty"` + ItemSanitize bool `json:"itemSanitize,omitempty"` + ItemMaxLength int `json:"itemMaxLength,omitempty"` + Pattern string `json:"pattern,omitempty"` + PatternError string `json:"patternError,omitempty"` + TemporaryID bool `json:"temporaryId,omitempty"` +} + +// TypeValidationConfig defines validation configuration for a safe output type +type TypeValidationConfig struct { + DefaultMax int `json:"defaultMax"` + Fields map[string]FieldValidation `json:"fields"` + CustomValidation string `json:"customValidation,omitempty"` +} + +// Constants for validation +const ( + MaxBodyLength = 65000 + MaxGitHubUsernameLength = 39 +) + +// ValidationConfig contains all safe output type validation rules +// This is the single source of truth for validation rules +var ValidationConfig = map[string]TypeValidationConfig{ + "create_issue": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "title": {Required: true, Type: "string", Sanitize: true, MaxLength: 128}, + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "labels": {Type: "array", ItemType: "string", ItemSanitize: true, ItemMaxLength: 128}, + "parent": {IssueOrPRNumber: true}, + "temporary_id": {Type: "string"}, + }, + }, + "create_agent_task": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + }, + }, + "add_comment": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "item_number": {IssueOrPRNumber: true}, + }, + }, + "create_pull_request": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "title": {Required: true, Type: "string", Sanitize: true, MaxLength: 128}, + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "branch": {Required: true, Type: "string", Sanitize: true, MaxLength: 256}, + "labels": {Type: "array", ItemType: "string", ItemSanitize: true, ItemMaxLength: 128}, + }, + }, + "add_labels": { + DefaultMax: 5, + Fields: map[string]FieldValidation{ + "labels": {Required: true, Type: "array", ItemType: "string", ItemSanitize: true, ItemMaxLength: 128}, + "item_number": {IssueOrPRNumber: true}, + }, + }, + "add_reviewer": { + DefaultMax: 3, + Fields: map[string]FieldValidation{ + "reviewers": {Required: true, Type: "array", ItemType: "string", ItemSanitize: true, ItemMaxLength: MaxGitHubUsernameLength}, + "pull_request_number": {IssueOrPRNumber: true}, + }, + }, + "assign_milestone": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "issue_number": {IssueOrPRNumber: true}, + "milestone_number": {Required: true, PositiveInteger: true}, + }, + }, + "assign_to_agent": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "issue_number": {Required: true, PositiveInteger: true}, + "agent": {Type: "string", Sanitize: true, MaxLength: 128}, + }, + }, + "update_issue": { + DefaultMax: 1, + CustomValidation: "requiresOneOf:status,title,body", + Fields: map[string]FieldValidation{ + "status": {Type: "string", Enum: []string{"open", "closed"}}, + "title": {Type: "string", Sanitize: true, MaxLength: 128}, + "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "issue_number": {IssueOrPRNumber: true}, + }, + }, + "update_pull_request": { + DefaultMax: 1, + CustomValidation: "requiresOneOf:title,body", + Fields: map[string]FieldValidation{ + "title": {Type: "string", Sanitize: true, MaxLength: 256}, + "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "operation": {Type: "string", Enum: []string{"replace", "append", "prepend"}}, + "pull_request_number": {IssueOrPRNumber: true}, + }, + }, + "push_to_pull_request_branch": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "branch": {Required: true, Type: "string", Sanitize: true, MaxLength: 256}, + "message": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "pull_request_number": {IssueOrPRNumber: true}, + }, + }, + "create_pull_request_review_comment": { + DefaultMax: 1, + CustomValidation: "startLineLessOrEqualLine", + Fields: map[string]FieldValidation{ + "path": {Required: true, Type: "string"}, + "line": {Required: true, PositiveInteger: true}, + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "start_line": {OptionalPositiveInteger: true}, + "side": {Type: "string", Enum: []string{"LEFT", "RIGHT"}}, + }, + }, + "create_discussion": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "title": {Required: true, Type: "string", Sanitize: true, MaxLength: 128}, + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "category": {Type: "string", Sanitize: true, MaxLength: 128}, + }, + }, + "close_discussion": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "reason": {Type: "string", Enum: []string{"RESOLVED", "DUPLICATE", "OUTDATED", "ANSWERED"}}, + "discussion_number": {OptionalPositiveInteger: true}, + }, + }, + "close_issue": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "issue_number": {OptionalPositiveInteger: true}, + }, + }, + "close_pull_request": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "pull_request_number": {OptionalPositiveInteger: true}, + }, + }, + "missing_tool": { + DefaultMax: 20, + Fields: map[string]FieldValidation{ + "tool": {Required: true, Type: "string", Sanitize: true, MaxLength: 128}, + "reason": {Required: true, Type: "string", Sanitize: true, MaxLength: 256}, + "alternatives": {Type: "string", Sanitize: true, MaxLength: 512}, + }, + }, + "update_release": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "tag": {Type: "string", Sanitize: true, MaxLength: 256}, + "operation": {Required: true, Type: "string", Enum: []string{"replace", "append", "prepend"}}, + "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + }, + }, + "upload_asset": { + DefaultMax: 10, + Fields: map[string]FieldValidation{ + "path": {Required: true, Type: "string"}, + }, + }, + "noop": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "message": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + }, + }, + "create_code_scanning_alert": { + DefaultMax: 40, + Fields: map[string]FieldValidation{ + "file": {Required: true, Type: "string", Sanitize: true, MaxLength: 512}, + "line": {Required: true, PositiveInteger: true}, + "severity": {Required: true, Type: "string", Enum: []string{"error", "warning", "info", "note"}}, + "message": {Required: true, Type: "string", Sanitize: true, MaxLength: 2048}, + "column": {OptionalPositiveInteger: true}, + "ruleIdSuffix": {Type: "string", Pattern: "^[a-zA-Z0-9_-]+$", PatternError: "must contain only alphanumeric characters, hyphens, and underscores", Sanitize: true, MaxLength: 128}, + }, + }, + "link_sub_issue": { + DefaultMax: 5, + CustomValidation: "parentAndSubDifferent", + Fields: map[string]FieldValidation{ + "parent_issue_number": {Required: true, IssueNumberOrTemporaryID: true}, + "sub_issue_number": {Required: true, IssueNumberOrTemporaryID: true}, + }, + }, +} + +// GetValidationConfigJSON returns the validation configuration as indented JSON +// If enabledTypes is empty or nil, returns all validation configs +// If enabledTypes is provided, returns only configs for the specified types +func GetValidationConfigJSON(enabledTypes []string) (string, error) { + var configToMarshal map[string]TypeValidationConfig + + if len(enabledTypes) == 0 { + // Return all configs (backwards compatible) + configToMarshal = ValidationConfig + } else { + // Filter to only enabled types + configToMarshal = make(map[string]TypeValidationConfig) + for _, typeName := range enabledTypes { + if config, ok := ValidationConfig[typeName]; ok { + configToMarshal[typeName] = config + } + } + } + + // Use MarshalIndent for pretty-printed JSON to avoid merge issues + data, err := json.MarshalIndent(configToMarshal, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +// GetValidationConfigForType returns the validation config for a specific type +func GetValidationConfigForType(typeName string) (TypeValidationConfig, bool) { + config, ok := ValidationConfig[typeName] + return config, ok +} + +// GetDefaultMaxForType returns the default max for a type +func GetDefaultMaxForType(typeName string) int { + if config, ok := ValidationConfig[typeName]; ok { + return config.DefaultMax + } + return 1 +} diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go new file mode 100644 index 00000000000..f0939a6fef9 --- /dev/null +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -0,0 +1,257 @@ +package workflow + +import ( + "encoding/json" + "testing" +) + +func TestGetValidationConfigJSON(t *testing.T) { + // Test with nil (all types) + jsonStr, err := GetValidationConfigJSON(nil) + if err != nil { + t.Fatalf("GetValidationConfigJSON() error = %v", err) + } + + // Verify it's valid JSON + var parsed map[string]TypeValidationConfig + err = json.Unmarshal([]byte(jsonStr), &parsed) + if err != nil { + t.Fatalf("Failed to parse validation config JSON: %v", err) + } + + // Verify all expected types are present + expectedTypes := []string{ + "create_issue", + "create_agent_task", + "add_comment", + "create_pull_request", + "add_labels", + "add_reviewer", + "assign_milestone", + "assign_to_agent", + "update_issue", + "update_pull_request", + "push_to_pull_request_branch", + "create_pull_request_review_comment", + "create_discussion", + "close_discussion", + "close_issue", + "close_pull_request", + "missing_tool", + "update_release", + "upload_asset", + "noop", + "create_code_scanning_alert", + "link_sub_issue", + } + + for _, typeName := range expectedTypes { + if _, ok := parsed[typeName]; !ok { + t.Errorf("Expected type %q not found in validation config", typeName) + } + } + + // Verify JSON is indented (contains newlines) + if !containsNewline(jsonStr) { + t.Error("Expected indented JSON output with newlines") + } +} + +func TestGetValidationConfigJSONFiltered(t *testing.T) { + // Test with filtered types + enabledTypes := []string{"create_issue", "add_comment"} + jsonStr, err := GetValidationConfigJSON(enabledTypes) + if err != nil { + t.Fatalf("GetValidationConfigJSON() error = %v", err) + } + + // Verify it's valid JSON + var parsed map[string]TypeValidationConfig + err = json.Unmarshal([]byte(jsonStr), &parsed) + if err != nil { + t.Fatalf("Failed to parse validation config JSON: %v", err) + } + + // Verify only enabled types are present + if len(parsed) != 2 { + t.Errorf("Expected 2 types, got %d", len(parsed)) + } + + if _, ok := parsed["create_issue"]; !ok { + t.Error("Expected create_issue to be present") + } + if _, ok := parsed["add_comment"]; !ok { + t.Error("Expected add_comment to be present") + } + + // Verify other types are NOT present + if _, ok := parsed["create_discussion"]; ok { + t.Error("Did not expect create_discussion to be present") + } +} + +func TestGetValidationConfigJSONEmpty(t *testing.T) { + // Test with empty slice (should return all types, same as nil) + jsonStr, err := GetValidationConfigJSON([]string{}) + if err != nil { + t.Fatalf("GetValidationConfigJSON() error = %v", err) + } + + var parsed map[string]TypeValidationConfig + err = json.Unmarshal([]byte(jsonStr), &parsed) + if err != nil { + t.Fatalf("Failed to parse validation config JSON: %v", err) + } + + // Empty slice should return all types + if len(parsed) != len(ValidationConfig) { + t.Errorf("Expected %d types with empty slice, got %d", len(ValidationConfig), len(parsed)) + } +} + +func containsNewline(s string) bool { + for _, r := range s { + if r == '\n' { + return true + } + } + return false +} + +func TestGetValidationConfigForType(t *testing.T) { + tests := []struct { + name string + typeName string + wantFound bool + wantMax int + wantFields []string + }{ + { + name: "create_issue type", + typeName: "create_issue", + wantFound: true, + wantMax: 1, + wantFields: []string{"title", "body", "labels", "parent", "temporary_id"}, + }, + { + name: "link_sub_issue type", + typeName: "link_sub_issue", + wantFound: true, + wantMax: 5, + wantFields: []string{"parent_issue_number", "sub_issue_number"}, + }, + { + name: "unknown type", + typeName: "unknown_type", + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, found := GetValidationConfigForType(tt.typeName) + if found != tt.wantFound { + t.Errorf("GetValidationConfigForType() found = %v, want %v", found, tt.wantFound) + } + if found { + if config.DefaultMax != tt.wantMax { + t.Errorf("DefaultMax = %v, want %v", config.DefaultMax, tt.wantMax) + } + for _, fieldName := range tt.wantFields { + if _, ok := config.Fields[fieldName]; !ok { + t.Errorf("Field %q not found in config", fieldName) + } + } + } + }) + } +} + +func TestGetDefaultMaxForType(t *testing.T) { + tests := []struct { + typeName string + want int + }{ + {"create_issue", 1}, + {"add_labels", 5}, + {"missing_tool", 20}, + {"create_code_scanning_alert", 40}, + {"link_sub_issue", 5}, + {"unknown_type", 1}, // Default fallback + } + + for _, tt := range tests { + t.Run(tt.typeName, func(t *testing.T) { + got := GetDefaultMaxForType(tt.typeName) + if got != tt.want { + t.Errorf("GetDefaultMaxForType(%q) = %v, want %v", tt.typeName, got, tt.want) + } + }) + } +} + +func TestFieldValidationMarshaling(t *testing.T) { + // Test that FieldValidation marshals correctly with omitempty + field := FieldValidation{ + Required: true, + Type: "string", + MaxLength: 128, + Sanitize: true, + } + + data, err := json.Marshal(field) + if err != nil { + t.Fatalf("Failed to marshal FieldValidation: %v", err) + } + + // Verify omitempty works - should not include false/zero values + jsonStr := string(data) + if jsonStr == "" { + t.Error("Empty JSON output") + } + + // Parse it back + var parsed FieldValidation + err = json.Unmarshal(data, &parsed) + if err != nil { + t.Fatalf("Failed to unmarshal FieldValidation: %v", err) + } + + if parsed.Required != field.Required { + t.Errorf("Required mismatch: got %v, want %v", parsed.Required, field.Required) + } + if parsed.Type != field.Type { + t.Errorf("Type mismatch: got %v, want %v", parsed.Type, field.Type) + } + if parsed.MaxLength != field.MaxLength { + t.Errorf("MaxLength mismatch: got %v, want %v", parsed.MaxLength, field.MaxLength) + } +} + +func TestValidationConfigConsistency(t *testing.T) { + // Verify that all types with customValidation have valid validation rules + validCustomValidations := map[string]bool{ + "requiresOneOf:status,title,body": true, + "requiresOneOf:title,body": true, + "startLineLessOrEqualLine": true, + "parentAndSubDifferent": true, + } + + for typeName, config := range ValidationConfig { + if config.CustomValidation != "" { + if !validCustomValidations[config.CustomValidation] { + t.Errorf("Type %q has unknown customValidation: %q", typeName, config.CustomValidation) + } + } + + // Verify all types have at least one field + if len(config.Fields) == 0 { + t.Errorf("Type %q has no fields defined", typeName) + } + + // Verify defaultMax is positive + if config.DefaultMax <= 0 { + t.Errorf("Type %q has invalid defaultMax: %d", typeName, config.DefaultMax) + } + } +} diff --git a/pkg/workflow/safe_outputs_runs_on_test.go b/pkg/workflow/safe_outputs_runs_on_test.go index 9ee948ca122..dba561fa12d 100644 --- a/pkg/workflow/safe_outputs_runs_on_test.go +++ b/pkg/workflow/safe_outputs_runs_on_test.go @@ -131,21 +131,22 @@ This is a test workflow.` } // Check specifically that the expected safe-outputs jobs use the custom runner + // Use a pattern that matches YAML job definitions at the correct indentation level + // to avoid matching JavaScript object properties inside bundled scripts expectedJobs := []string{"create_issue:", "create_issue_comment:", "add_labels:", "update_issue:"} for _, jobName := range expectedJobs { - if strings.Contains(yamlStr, jobName) { - // Find the job section - jobStart := strings.Index(yamlStr, jobName) - if jobStart != -1 { - // Look for runs-on within the next 500 characters of this job - jobSection := yamlStr[jobStart : jobStart+500] - defaultRunsOn := fmt.Sprintf("runs-on: %s", constants.DefaultActivationJobRunnerImage) - if strings.Contains(jobSection, defaultRunsOn) { - t.Errorf("Job %q still uses default %q instead of custom runner.\nJob section:\n%s", jobName, defaultRunsOn, jobSection) - } - if !strings.Contains(jobSection, expectedRunsOn) { - t.Errorf("Job %q does not use expected %q.\nJob section:\n%s", jobName, expectedRunsOn, jobSection) - } + // Look for the job name at YAML indentation level (2 spaces under 'jobs:') + yamlJobPattern := "\n " + jobName + jobStart := strings.Index(yamlStr, yamlJobPattern) + if jobStart != -1 { + // Look for runs-on within the next 500 characters of this job + jobSection := yamlStr[jobStart : jobStart+500] + defaultRunsOn := fmt.Sprintf("runs-on: %s", constants.DefaultActivationJobRunnerImage) + if strings.Contains(jobSection, defaultRunsOn) { + t.Errorf("Job %q still uses default %q instead of custom runner.\nJob section:\n%s", jobName, defaultRunsOn, jobSection) + } + if !strings.Contains(jobSection, expectedRunsOn) { + t.Errorf("Job %q does not use expected %q.\nJob section:\n%s", jobName, expectedRunsOn, jobSection) } } }