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)
}
}
}