Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions actions/setup/js/collect_ndjson_output.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async function main() {

// Extract mentions configuration from validation config
const mentionsConfig = validationConfig?.mentions || null;
const maxMentions = parseIntTemplatable(mentionsConfig?.max, 50);

// Resolve allowed mentions for the output collector
// This determines which @mentions are allowed in the agent output
Expand Down Expand Up @@ -66,7 +67,7 @@ async function main() {
error: `Line ${lineNum}: ${fieldName} must be a string`,
};
}
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxBotMentions });
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxMentions, maxBotMentions });
break;
case "boolean":
if (typeof value !== "boolean") {
Expand Down Expand Up @@ -97,11 +98,11 @@ async function main() {
error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`,
};
}
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxBotMentions });
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxMentions, maxBotMentions });
break;
default:
if (typeof value === "string") {
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxBotMentions });
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxMentions, maxBotMentions });
}
break;
}
Expand Down Expand Up @@ -357,6 +358,7 @@ async function main() {
if (hasValidationConfig(itemType)) {
const validationResult = validateItem(item, itemType, i + 1, {
allowedAliases: allowedMentions,
maxMentions,
maxBotMentions,
Comment on lines 360 to 363

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed: this is already addressed by 92deabd. validateItem now creates one allowedAliasesSeen set for the output item and supplies it to each field sanitizer; the collector’s safe-job path does the same. The regression test verifies the cap spans title and body while allowing a repeated approved identity.

normalizeIssueClosingKeywords,
dataEnabled: typeConfig !== null && typeof typeConfig === "object" && typeConfig.data_enabled === true,
Expand Down
19 changes: 19 additions & 0 deletions actions/setup/js/collect_ndjson_output.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,25 @@ describe("collect_ndjson_output.cjs", () => {
parsedOutput = JSON.parse(outputCall[1]);
expect(parsedOutput.items[0].body).toBe("Hey `@username` and `@org/team`, check this out! But preserve email@domain.com");
}),
it("should preserve allowed aliases after max when no more than max occur", async () => {
const allowed = Array.from({ length: 60 }, (_, i) => `user${i}`);
const validationPath = "/tmp/gh-aw/safeoutputs/validation.json";
const validationConfig = JSON.parse(fs.readFileSync(validationPath, "utf8"));
validationConfig.mentions = { allowContext: false, allowed, max: 3 };
fs.writeFileSync(validationPath, JSON.stringify(validationConfig));

const testFile = "/tmp/gh-aw/test-ndjson-output.txt";
const ndjsonContent = '{"type":"create_issue","title":"Late allowlist entries","body":"Thanks @user57, @user58, and @user59"}';
fs.writeFileSync(testFile, ndjsonContent);
process.env.GH_AW_SAFE_OUTPUTS = testFile;
fs.writeFileSync("/tmp/gh-aw/safeoutputs/config.json", '{"create_issue":true}');

await eval(`(async () => { ${collectScript}; await main(); })()`);

const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
const parsedOutput = JSON.parse(outputCall[1]);
expect(parsedOutput.items[0].body).toBe("Thanks @user57, @user58, and @user59");
}),
it("should neutralize bot trigger phrases", async () => {
const testFile = "/tmp/gh-aw/test-ndjson-output.txt",
ndjsonContent = '{"type": "create_issue", "title": "Bot Trigger Test", "body": "This fixes #123 and closes #456, also resolves #789"}';
Expand Down
23 changes: 15 additions & 8 deletions actions/setup/js/resolve_mentions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function getRepoCacheKey(owner, repo) {
* @property {string[]} allowedMentions - List of allowed mention usernames
* @property {number} totalMentions - Total number of mentions found
* @property {number} resolvedCount - Number of mentions resolved via API
* @property {boolean} limitExceeded - Whether the 50 mention limit was exceeded
* @property {boolean} limitExceeded - Whether the 50 unknown-mention resolution limit was exceeded
*/

/**
Expand Down Expand Up @@ -172,12 +172,22 @@ async function resolveMentionsLazily(text, knownAuthors, owner, repo, github, co

core.info(`Found ${totalMentions} unique mentions in text`);

// Limit to 50 mentions - filter out excess without API lookup
const limitExceeded = totalMentions > 50;
const mentionsToProcess = limitExceeded ? mentions.slice(0, 50) : mentions;
// Build set of known allowed authors (case-insensitive)
const knownAuthorsLowercase = new Set(knownAuthors.filter(a => a).map(a => a.toLowerCase()));

// Limit unknown candidates to 50 while always preserving pre-authorized mentions.
let unknownMentionCount = 0;
const mentionsToProcess = mentions.filter(mention => {
if (knownAuthorsLowercase.has(mention.toLowerCase())) {
return true;
}
unknownMentionCount++;
return unknownMentionCount <= 50;
});
const limitExceeded = unknownMentionCount > 50;

if (limitExceeded) {
core.warning(`Mention limit exceeded: ${totalMentions} mentions found, processing only first 50`);
core.warning(`Mention limit exceeded: ${unknownMentionCount} unknown mentions found, processing only first 50`);
}

if (mentionsToProcess.length === 0) {
Expand All @@ -190,9 +200,6 @@ async function resolveMentionsLazily(text, knownAuthors, owner, repo, github, co
};
}

// Build set of known allowed authors (case-insensitive)
const knownAuthorsLowercase = new Set(knownAuthors.filter(a => a).map(a => a.toLowerCase()));

// Optimistically fetch recent collaborators (first page only)
const collaboratorCache = await getRecentCollaborators(owner, repo, github, core);
core.info(`Cached ${collaboratorCache.size} recent collaborators for optimistic resolution`);
Expand Down
6 changes: 6 additions & 0 deletions actions/setup/js/resolve_mentions.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ describe("resolve_mentions.cjs", () => {
result = await resolveMentionsLazily(mentions, [], "owner", "repo", mockGithub, mockCore);
(expect(result.totalMentions).toBe(60), expect(result.limitExceeded).toBe(!0), expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Mention limit exceeded")));
}),
it("should not count known authors toward the 50 mention resolution limit", async () => {
const knownAuthors = Array.from({ length: 60 }, (_, i) => `known${i}`),
mentions = knownAuthors.map(author => `@${author}`).join(" "),
result = await resolveMentionsLazily(mentions, knownAuthors, "owner", "repo", mockGithub, mockCore);
(expect(result.allowedMentions).toEqual(knownAuthors), expect(result.limitExceeded).toBe(!1));
}),
it("should preserve case in allowed mentions", async () => {
mockGithub.rest.repos.listCollaborators.mockResolvedValue({ data: [{ login: "maintainer1", type: "User", permissions: { maintain: !0, admin: !1, push: !1 } }] });
const result = await resolveMentionsLazily("Hello @Maintainer1", [], "owner", "repo", mockGithub, mockCore);
Expand Down
14 changes: 2 additions & 12 deletions actions/setup/js/resolve_mentions_from_payload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,6 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions
const allowContext = mentionsConfig?.allowContext !== false; // default: true
const allowedList = mentionsConfig?.allowed || [];
const allowedTeams = mentionsConfig?.allowedTeams || [];
const maxMentions = mentionsConfig?.max || 50;

try {
const { owner, repo } = context.repo;
Expand Down Expand Up @@ -234,23 +233,14 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions
// If collaborator mentions are disabled, only use known authors (context + allowed list)
if (!allowCollaboratorMentions) {
core.info(`[MENTIONS] Collaborator mentions disabled - only allowing context (${deduplicatedKnownAuthors.length} users)`);
if (deduplicatedKnownAuthors.length > maxMentions) {
core.warning(`[MENTIONS] Mention limit exceeded: ${deduplicatedKnownAuthors.length} mentions, limiting to ${maxMentions}`);
}
return deduplicatedKnownAuthors.slice(0, maxMentions);
return deduplicatedKnownAuthors;
Comment on lines 233 to +236

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 92deabd: all built-in final-write handlers and synthetic-update helpers parse and pass mentions.max to sanitizeContent, preserving the cap for newly assembled output.

}

// Build allowed mentions list from known authors and collaborators
// We pass the known authors as fake mentions in text so they get processed
const fakeText = deduplicatedKnownAuthors.map(author => `@${author}`).join(" ");
const mentionResult = await resolveMentionsLazily(fakeText, deduplicatedKnownAuthors, owner, repo, github, core);
let allowedMentions = mentionResult.allowedMentions;

// Apply max limit
if (allowedMentions.length > maxMentions) {
core.warning(`[MENTIONS] Mention limit exceeded: ${allowedMentions.length} mentions, limiting to ${maxMentions}`);
allowedMentions = allowedMentions.slice(0, maxMentions);
}
const allowedMentions = mentionResult.allowedMentions;

if (allowedMentions.length > 0) {
core.info(`[OUTPUT COLLECTOR] Allowed mentions: ${allowedMentions.join(", ")}`);
Expand Down
25 changes: 11 additions & 14 deletions actions/setup/js/resolve_mentions_from_payload.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ describe("resolveAllowedMentionsFromPayload", () => {
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("extra known author"));
});

it("applies max limit when allowTeamMembers is false", async () => {
it("does not truncate known authors to max when allowTeamMembers is false", async () => {
const context = {
eventName: "issues",
payload: {
Expand All @@ -336,25 +336,22 @@ describe("resolveAllowedMentionsFromPayload", () => {
allowTeamMembers: false,
max: 3,
});
expect(result.length).toBeLessThanOrEqual(3);
expect(result).toEqual(["alice", "user0", "user1", "user2", "user3", "user4"]);
});

it("warns when mention limit is exceeded with team members disabled", async () => {
it("does not truncate resolved aliases to max", async () => {
const context = {
eventName: "issues",
payload: {
issue: {
user: null,
assignees: Array.from({ length: 5 }, (_, i) => ({ login: `user${i}`, type: "User" })),
},
},
eventName: "workflow_dispatch",
payload: {},
repo: { owner: "o", repo: "r" },
};
await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore, {
allowTeamMembers: false,
max: 2,
const allowed = Array.from({ length: 60 }, (_, i) => `user${i}`);
const result = await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore, {
allowContext: false,
allowed,
max: 3,
});
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Mention limit exceeded"));
expect(result).toEqual(allowed);
});

it("returns empty array and logs warning on error", async () => {
Expand Down
7 changes: 7 additions & 0 deletions actions/setup/js/safe_output_type_validator.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const ISSUE_INTENT_RATIONALE_MAX_LENGTH = 280;
/**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actions/setup/js/safe_output_type_validator.cjs:31-39: yagni: generic ValidateOptions plumbing for a single maxMentions knob. Inline the single sanitizeContent call site instead of threading a new option through the validator layer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applying this simplification: validation sanitizes several fields and arrays for each safe-output type. Passing the existing validation options is necessary to share the per-item cap across those fields without changing validation behavior.

* @typedef {{
* allowedAliases?: string[],
* maxMentions?: number,
* maxBotMentions?: number,
* normalizeIssueClosingKeywords?: boolean,
* dataEnabled?: boolean,
Expand Down Expand Up @@ -90,6 +91,7 @@ function normalizeIssueIntentRationale(rationale, options) {
const sanitizedRationale = sanitizeContent(unfenceMarkdown(rationale), {
maxLength: ISSUE_INTENT_RATIONALE_MAX_LENGTH,
allowedAliases: options?.allowedAliases || [],
maxMentions: options?.maxMentions,
maxBotMentions: options?.maxBotMentions,
}).trim();
// sanitizeContent appends "\n[Content truncated due to length]" when it truncates,
Expand All @@ -116,6 +118,7 @@ function validateIssueIntentLabels(value, lineNum, itemType, fieldName, options)
const name = sanitizeContent(label, {
maxLength: 128,
allowedAliases: options?.allowedAliases || [],
maxMentions: options?.maxMentions,
maxBotMentions: options?.maxBotMentions,
});
if (!name) {
Expand Down Expand Up @@ -149,6 +152,7 @@ function validateIssueIntentLabels(value, lineNum, itemType, fieldName, options)
const name = sanitizeContent(label.name, {
maxLength: 128,
allowedAliases: options?.allowedAliases || [],
maxMentions: options?.maxMentions,
maxBotMentions: options?.maxBotMentions,
});
if (!name) {
Expand Down Expand Up @@ -544,6 +548,7 @@ function validateField(value, fieldName, validation, itemType, lineNum, options)
normalizedResult = sanitizeContent(normalizedResult, {
maxLength: validation.maxLength,
allowedAliases: options?.allowedAliases || [],
maxMentions: options?.maxMentions,
maxBotMentions: options?.maxBotMentions,
});
}
Expand All @@ -557,6 +562,7 @@ function validateField(value, fieldName, validation, itemType, lineNum, options)
finalValue = sanitizeContent(unfenceMarkdown(value), {
maxLength: validation.maxLength || MAX_BODY_LENGTH,
allowedAliases: options?.allowedAliases || [],
maxMentions: options?.maxMentions,
maxBotMentions: options?.maxBotMentions,
});
}
Expand Down Expand Up @@ -635,6 +641,7 @@ function validateField(value, fieldName, validation, itemType, lineNum, options)
? sanitizeContent(item, {
maxLength: validation.itemMaxLength || 128,
allowedAliases: options?.allowedAliases || [],
maxMentions: options?.maxMentions,
maxBotMentions: options?.maxBotMentions,
})
: item
Expand Down
19 changes: 14 additions & 5 deletions actions/setup/js/sanitize_content.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const RUNTIME_TO_MENTION_ALIAS_MAP = {
* @typedef {Object} SanitizeOptions
* @property {number} [maxLength] - Maximum length of content (default: 524288)
* @property {string[]} [allowedAliases] - List of aliases (@mentions) that should not be neutralized
* @property {number} [maxMentions] - Maximum number of unique allowed aliases to preserve
* @property {number} [maxBotMentions] - Maximum bot trigger references before filtering (default: 10)
Comment on lines 41 to 46

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 92deabd: /home/runner/work/gh-aw/gh-aw/actions/setup/js/types/safe-output-script.d.ts now declares maxMentions?: number in SanitizeOptions.

*/

Expand All @@ -57,6 +58,8 @@ function sanitizeContent(content, maxLengthOrOptions) {
/** @type {string[]} */
let allowedAliasesLowercase = [];
/** @type {number | undefined} */
let maxMentions;
/** @type {number | undefined} */
let maxBotMentions;

if (typeof maxLengthOrOptions === "number") {
Expand All @@ -66,6 +69,7 @@ function sanitizeContent(content, maxLengthOrOptions) {
// Pre-process allowed aliases to lowercase for efficient comparison
const normalizedAllowedAliases = normalizeAllowedAliases(maxLengthOrOptions.allowedAliases);
allowedAliasesLowercase = expandAllowedAliases(normalizedAllowedAliases);
maxMentions = maxLengthOrOptions.maxMentions;
maxBotMentions = maxLengthOrOptions.maxBotMentions;
}

Expand Down Expand Up @@ -124,7 +128,7 @@ function sanitizeContent(content, maxLengthOrOptions) {

// Neutralize mentions after truncation so the length boundary cannot split an
// inserted code-span delimiter and reactivate a mention.
sanitized = neutralizeMentions(sanitized, allowedAliasesLowercase);
sanitized = neutralizeMentions(sanitized, allowedAliasesLowercase, maxMentions);

// Neutralize GitHub references if restrictions are configured
sanitized = neutralizeGitHubReferences(sanitized, allowedGitHubRefs);
Expand Down Expand Up @@ -185,18 +189,23 @@ function sanitizeContent(content, maxLengthOrOptions) {
* Neutralize @mentions with selective filtering
* @param {string} s - The string to process
* @param {string[]} allowedLowercase - List of allowed aliases (lowercase)
* @param {number | undefined} maxAllowed - Maximum number of unique allowed aliases to preserve
* @returns {string} Processed string
*/
function neutralizeMentions(s, allowedLowercase) {
function neutralizeMentions(s, allowedLowercase, maxAllowed) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actions/setup/js/sanitize_content.cjs:195-205: yagni: per-call mention-count state inside sanitizeContent. Drop the extra maxMentions limiter and let the caller pre-filter aliases once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applying this suggestion: pre-filtering aliases reintroduces the reported defect by making allowlist order decide authorization. The limiter must evaluate approved identities as they occur in the output; its state is intentionally local to a message/item.

const wrapInCodeSpan = createRenderSafeCodeSpanWrapper(s);
const allowedAliasesSeen = new Set();
return applyToNonCodeRegions(s, (segment, regionBefore = "", regionAfter = "") => {
return segment.replace(/(^|[^A-Za-z0-9])@([A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (match, prefix, alias, offset) => {
const isAllowed = allowedLowercase.includes(alias.toLowerCase());
if (isAllowed) {
const normalizedAlias = alias.toLowerCase();
const isAllowed = allowedLowercase.includes(normalizedAlias);
const isWithinLimit = maxAllowed === undefined || allowedAliasesSeen.has(normalizedAlias) || allowedAliasesSeen.size < maxAllowed;
if (isAllowed && isWithinLimit) {
allowedAliasesSeen.add(normalizedAlias);
return `${prefix}@${alias}`;
}
if (typeof core !== "undefined" && core.info) {
core.info(`Escaped mention: @${alias} (not in allowed list)`);
core.info(isAllowed ? `Escaped mention: @${alias} (mention limit exceeded)` : `Escaped mention: @${alias} (not in allowed list)`);
}
const before = prefix || (offset === 0 ? regionBefore : "");
const after = segment[offset + match.length] || (offset + match.length === segment.length ? regionAfter : "");
Expand Down
17 changes: 17 additions & 0 deletions actions/setup/js/sanitize_content.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,23 @@ describe("sanitize_content.cjs", () => {
expect(result).toBe("Hello @user1 and @user2 and `@other`");
});

it("should apply max only to allowed aliases present in the message", () => {
const allowedAliases = Array.from({ length: 10 }, (_, i) => `user${i}`);
const result = sanitizeContent("Hello @user7, @user8, and @user9", {
allowedAliases,
maxMentions: 3,
});
expect(result).toBe("Hello @user7, @user8, and @user9");
});

it("should neutralize additional distinct allowed aliases after max is reached", () => {
const result = sanitizeContent("@user1 @other @user2 @user3 @user1", {
allowedAliases: ["user1", "user2", "user3"],
maxMentions: 2,
});
expect(result).toBe("@user1 `@other` @user2 `@user3` @user1");
});

it("should work with options object containing both maxLength and allowedAliases", () => {
const result = sanitizeContent("Hello @author and @other", {
maxLength: 524288,
Expand Down
Loading