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
6 changes: 5 additions & 1 deletion actions/setup/js/assign_to_user.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const HANDLER_TYPE = "assign_to_user";
async function main(config = {}) {
// Extract configuration
const allowedAssignees = config.allowed || [];
const blockedAssignees = config.blocked || [];

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.

Nice addition of blockedAssignees support. Consider adding a comment here explaining that blocked takes precedence over allowed to clarify the filtering priority for future readers.

const maxCount = config.max || 10;
const unassignFirst = config.unassign_first || false;
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
Expand All @@ -32,6 +33,9 @@ async function main(config = {}) {
if (allowedAssignees.length > 0) {
core.info(`Allowed assignees: ${allowedAssignees.join(", ")}`);
}
if (blockedAssignees.length > 0) {
core.info(`Blocked assignees: ${blockedAssignees.join(", ")}`);
}
core.info(`Default target repo: ${defaultTargetRepo}`);
if (allowedRepos.size > 0) {
core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
Expand Down Expand Up @@ -89,7 +93,7 @@ async function main(config = {}) {
core.info(`Requested assignees: ${JSON.stringify(requestedAssignees)}`);

// Use shared helper to filter, sanitize, dedupe, and limit
const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount);
const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount, blockedAssignees);

if (uniqueAssignees.length === 0) {
core.info("No assignees to add");
Expand Down
102 changes: 102 additions & 0 deletions actions/setup/js/assign_to_user.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -483,4 +483,106 @@ describe("assign_to_user (Handler Factory Architecture)", () => {
assignees: ["new-user1"],
});
});

describe("blocked patterns", () => {
it("should filter out blocked users by exact match", async () => {
const { main } = require("./assign_to_user.cjs");
const handler = await main({

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.

Good test coverage for blocked patterns. It would also be worth adding a test case that verifies an empty blocked array (the default) doesn't filter any valid assignees.

max: 10,
blocked: ["copilot", "admin"],
});

mockGithub.rest.issues.addAssignees.mockResolvedValue({});

const message = {
type: "assign_to_user",
assignees: ["user1", "copilot", "admin", "user2"],
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(result.assigneesAdded).toEqual(["user1", "user2"]);
expect(mockGithub.rest.issues.addAssignees).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
issue_number: 123,
assignees: ["user1", "user2"],
});
});

it("should filter out blocked users by pattern", async () => {
const { main } = require("./assign_to_user.cjs");
const handler = await main({
max: 10,
blocked: ["*[bot]"],
});

mockGithub.rest.issues.addAssignees.mockResolvedValue({});

const message = {
type: "assign_to_user",
assignees: ["user1", "dependabot[bot]", "github-actions[bot]", "user2"],
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(result.assigneesAdded).toEqual(["user1", "user2"]);
expect(mockGithub.rest.issues.addAssignees).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
issue_number: 123,
assignees: ["user1", "user2"],
});
});

it("should combine allowed and blocked filters", async () => {
const { main } = require("./assign_to_user.cjs");
const handler = await main({
max: 10,
allowed: ["user1", "user2", "copilot", "github-actions[bot]"],
blocked: ["copilot", "*[bot]"],
});

mockGithub.rest.issues.addAssignees.mockResolvedValue({});

const message = {
type: "assign_to_user",
assignees: ["user1", "user2", "copilot", "github-actions[bot]", "unauthorized"],
};

const result = await handler(message, {});

expect(result.success).toBe(true);
// Should only include user1 and user2 (allowed and not blocked)
expect(result.assigneesAdded).toEqual(["user1", "user2"]);
expect(mockGithub.rest.issues.addAssignees).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
issue_number: 123,
assignees: ["user1", "user2"],
});
});

it("should return success with empty array when all assignees are blocked", async () => {
const { main } = require("./assign_to_user.cjs");
const handler = await main({
max: 10,
blocked: ["*[bot]"],
});

const message = {
type: "assign_to_user",
assignees: ["dependabot[bot]", "github-actions[bot]"],
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(result.assigneesAdded).toEqual([]);
expect(result.message).toContain("No valid assignees found");
expect(mockGithub.rest.issues.addAssignees).not.toHaveBeenCalled();
});
});
});
43 changes: 43 additions & 0 deletions actions/setup/js/safe_output_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,54 @@ function extractAssignees(message) {
return requestedAssignees;
}

/**
* Check if a username matches a blocked pattern
* Supports exact matching and glob-style patterns (e.g., "*[bot]")
* @param {string} username - The username to check
* @param {string} pattern - The pattern to match against (e.g., "copilot", "*[bot]")
* @returns {boolean} True if username matches the blocked pattern
*/
function matchesBlockedPattern(username, pattern) {
Comment thread
pelikhan marked this conversation as resolved.
if (!username || !pattern) {
return false;
}

// Exact match
if (username === pattern) {
return true;
}

// Simple glob pattern matching
// Convert glob pattern to regex: "*[bot]" -> "^.*\[bot\]$"
const regexPattern = pattern

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot review for security

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.

Refactored in commit 7102cba. The regex pattern escaping now uses the generic simpleGlobToRegex() function from glob_pattern_helpers.cjs, which properly escapes all special regex characters except *.

.replace(/[.+?^${}()|[\]\\]/g, "\\$&") // Escape special regex chars except *
.replace(/\*/g, ".*"); // Convert * to .*

const regex = new RegExp(`^${regexPattern}$`, "i"); // Case-insensitive

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot review for security

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.

Refactored in commit 7102cba. The regex construction is now delegated to matchesSimpleGlob() in glob_pattern_helpers.cjs, which handles case-insensitive matching by default.

return regex.test(username);
}

/**
* Check if a username is blocked by any pattern in the blocked list
* @param {string} username - The username to check
* @param {string[]|undefined} blockedPatterns - Array of blocked patterns (e.g., ["copilot", "*[bot]"])
* @returns {boolean} True if username is blocked
*/
function isUsernameBlocked(username, blockedPatterns) {
if (!blockedPatterns || blockedPatterns.length === 0) {
return false;
}

return blockedPatterns.some(pattern => matchesBlockedPattern(username, pattern));
}

module.exports = {
parseAllowedItems,
parseMaxCount,
resolveTarget,
loadCustomSafeOutputJobTypes,
resolveIssueNumber,
extractAssignees,
matchesBlockedPattern,
isUsernameBlocked,
};
79 changes: 79 additions & 0 deletions actions/setup/js/safe_output_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -502,4 +502,83 @@ describe("safe_output_helpers", () => {
expect(result.size).toBe(0);
});
});

describe("matchesBlockedPattern", () => {
it("should match exact username", () => {
expect(helpers.matchesBlockedPattern("copilot", "copilot")).toBe(true);
});

it("should be case-insensitive for exact match", () => {
expect(helpers.matchesBlockedPattern("Copilot", "copilot")).toBe(true);
expect(helpers.matchesBlockedPattern("COPILOT", "copilot")).toBe(true);
});

it("should not match different usernames", () => {
expect(helpers.matchesBlockedPattern("alice", "copilot")).toBe(false);
});

it("should match wildcard pattern *[bot]", () => {
expect(helpers.matchesBlockedPattern("dependabot[bot]", "*[bot]")).toBe(true);
expect(helpers.matchesBlockedPattern("github-actions[bot]", "*[bot]")).toBe(true);
expect(helpers.matchesBlockedPattern("renovate[bot]", "*[bot]")).toBe(true);
});

it("should not match non-bot usernames with *[bot] pattern", () => {
expect(helpers.matchesBlockedPattern("alice", "*[bot]")).toBe(false);
expect(helpers.matchesBlockedPattern("bot-user", "*[bot]")).toBe(false);
});

it("should match wildcard at end", () => {
expect(helpers.matchesBlockedPattern("github-actions-bot", "github-*")).toBe(true);
expect(helpers.matchesBlockedPattern("github-bot", "github-*")).toBe(true);
});

it("should match wildcard at start", () => {
expect(helpers.matchesBlockedPattern("my-bot", "*-bot")).toBe(true);
expect(helpers.matchesBlockedPattern("github-bot", "*-bot")).toBe(true);
});

it("should handle empty or null inputs", () => {
expect(helpers.matchesBlockedPattern("", "copilot")).toBe(false);
expect(helpers.matchesBlockedPattern("copilot", "")).toBe(false);
expect(helpers.matchesBlockedPattern(null, "copilot")).toBe(false);
expect(helpers.matchesBlockedPattern("copilot", null)).toBe(false);
});

it("should escape special regex characters", () => {
expect(helpers.matchesBlockedPattern("user.name", "user.name")).toBe(true);
expect(helpers.matchesBlockedPattern("user+test", "user+test")).toBe(true);
});
});

describe("isUsernameBlocked", () => {
it("should return false for empty blocked list", () => {
expect(helpers.isUsernameBlocked("copilot", [])).toBe(false);
});

it("should return false for undefined blocked list", () => {
expect(helpers.isUsernameBlocked("copilot", undefined)).toBe(false);
});

it("should return true if username matches any pattern", () => {
const blocked = ["copilot", "*[bot]"];
expect(helpers.isUsernameBlocked("copilot", blocked)).toBe(true);
expect(helpers.isUsernameBlocked("dependabot[bot]", blocked)).toBe(true);
});

it("should return false if username matches no patterns", () => {
const blocked = ["copilot", "*[bot]"];
expect(helpers.isUsernameBlocked("alice", blocked)).toBe(false);
expect(helpers.isUsernameBlocked("bob", blocked)).toBe(false);
});

it("should handle multiple patterns", () => {
const blocked = ["admin", "copilot", "*[bot]", "test-*"];
expect(helpers.isUsernameBlocked("admin", blocked)).toBe(true);
expect(helpers.isUsernameBlocked("copilot", blocked)).toBe(true);
expect(helpers.isUsernameBlocked("github-actions[bot]", blocked)).toBe(true);
expect(helpers.isUsernameBlocked("test-user", blocked)).toBe(true);
expect(helpers.isUsernameBlocked("alice", blocked)).toBe(false);
});
});
});
34 changes: 31 additions & 3 deletions actions/setup/js/safe_output_processor.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,29 @@ function filterByAllowed(items, allowed) {
return items.filter(item => allowed.includes(item));
}

/**
* Filter out items matching blocked patterns
* @param {string[]} items - Items to filter
* @param {string[]|undefined} blockedPatterns - Blocked patterns list (undefined means no blocking)
* @returns {string[]} Filtered items with blocked items removed
*/
function filterByBlocked(items, blockedPatterns) {

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

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

The safe_output_processor.test.cjs file is missing test coverage for the new filterByBlocked function. The function is exported from the module and used in the processItems pipeline, but there are no direct tests to verify its behavior.

Consider adding tests for:

  • Filtering items with exact pattern matches
  • Filtering items with glob patterns
  • Handling empty or undefined blocked patterns
  • Integration with the processItems pipeline

Copilot uses AI. Check for mistakes.
if (!blockedPatterns || blockedPatterns.length === 0) {
return items;
}

// Import the pattern matching function
const { isUsernameBlocked } = require("./safe_output_helpers.cjs");

return items.filter(item => {
const blocked = isUsernameBlocked(item, blockedPatterns);
if (blocked) {
core.info(`Filtering out blocked item: ${item}`);
}
return !blocked;
});
}

/**
* Limit items to max count
* @param {string[]} items - Items to limit
Expand All @@ -260,18 +283,22 @@ function limitToMaxCount(items, maxCount) {
}

/**
* Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit
* Process items through the standard pipeline: filter by allowed, filter blocked, sanitize, dedupe, limit
* @param {any[]} rawItems - Raw items array from agent output
* @param {string[]|undefined} allowed - Allowed items list
* @param {number} maxCount - Maximum number of items
* @param {string[]|undefined} blocked - Blocked patterns list (optional)
* @returns {string[]} Processed items
*/
function processItems(rawItems, allowed, maxCount) {
function processItems(rawItems, allowed, maxCount, blocked = undefined) {
// Filter by allowed list first
const filtered = filterByAllowed(rawItems, allowed);

// Filter out blocked items
const notBlocked = filterByBlocked(filtered, blocked);

// Sanitize and deduplicate
const sanitized = sanitizeItems(filtered);
const sanitized = sanitizeItems(notBlocked);

// Limit to max count
return limitToMaxCount(sanitized, maxCount);
Expand All @@ -281,6 +308,7 @@ module.exports = {
processSafeOutput,
sanitizeItems,
filterByAllowed,
filterByBlocked,
limitToMaxCount,
processItems,
};
6 changes: 5 additions & 1 deletion actions/setup/js/unassign_from_user.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const HANDLER_TYPE = "unassign_from_user";
async function main(config = {}) {
// Extract configuration
const allowedAssignees = config.allowed || [];
const blockedAssignees = config.blocked || [];

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

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

The unassign_from_user.test.cjs file is missing test coverage for the new blocked patterns feature. While assign_to_user.test.cjs has comprehensive tests for blocked patterns (lines 487-587), the unassign_from_user handler should have similar test coverage to ensure the blocked filtering works correctly for unassignments as well.

Consider adding tests for:

  • Filtering out blocked users by exact match
  • Filtering out blocked users by pattern (e.g., *[bot])
  • Combining allowed and blocked filters
  • Handling empty results when all users are blocked

Copilot uses AI. Check for mistakes.
const maxCount = config.max || 10;

// Resolve target repository configuration
Expand All @@ -33,6 +34,9 @@ async function main(config = {}) {
if (allowedAssignees.length > 0) {
core.info(`Allowed assignees to unassign: ${allowedAssignees.join(", ")}`);
}
if (blockedAssignees.length > 0) {
core.info(`Blocked assignees to unassign: ${blockedAssignees.join(", ")}`);
}
core.info(`Default target repository: ${defaultTargetRepo}`);
if (allowedRepos.size > 0) {
core.info(`Additional allowed repositories: ${Array.from(allowedRepos).join(", ")}`);
Expand Down Expand Up @@ -78,7 +82,7 @@ async function main(config = {}) {
core.info(`Requested assignees to unassign: ${JSON.stringify(requestedAssignees)}`);

// Use shared helper to filter, sanitize, dedupe, and limit
const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount);
const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount, blockedAssignees);

if (uniqueAssignees.length === 0) {
core.info("No assignees to remove");
Expand Down
12 changes: 8 additions & 4 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -1370,26 +1370,30 @@ When `allowed` list is configured, existing agent assignees not in the list are

### Assign to User (`assign-to-user:`)

Assigns users to issues. Restrict with `allowed` list. Target: `"triggering"` (issue event), `"*"` (any), or number. Supports single or multiple assignees.
Assigns users to issues. Restrict with `allowed` list or deny with `blocked` patterns. Target: `"triggering"` (issue event), `"*"` (any), or number. Supports single or multiple assignees.

```yaml wrap
safe-outputs:
assign-to-user:
allowed: [user1, user2] # restrict to specific users
allowed: [user1, user2] # restrict to specific users (optional)
blocked: [copilot, "*[bot]"] # deny specific users or patterns (optional)
max: 3 # max assignments (default: 1)
target: "*" # "triggering" (default), "*", or number
target-repo: "owner/repo" # cross-repository
unassign-first: true # unassign all current assignees before assigning (default: false)
```

**Blocked Patterns**: Supports exact matches (e.g., `copilot`) and glob patterns (e.g., `*[bot]` to block all bot accounts). Blocked filtering is applied after allowed filtering, providing defense-in-depth against prompt injection attacks.

### Unassign from User (`unassign-from-user:`)

Removes user assignments from issues or pull requests. Restrict with `allowed` list to control which users can be unassigned. Target: `"triggering"` (issue/PR event), `"*"` (any), or number.
Removes user assignments from issues or pull requests. Restrict with `allowed` list or deny with `blocked` patterns to control which users can be unassigned. Target: `"triggering"` (issue/PR event), `"*"` (any), or number.

```yaml wrap
safe-outputs:
unassign-from-user:
allowed: [user1, user2] # restrict to specific users
allowed: [user1, user2] # restrict to specific users (optional)
blocked: [copilot, "*[bot]"] # deny specific users or patterns (optional)
max: 1 # max unassignments (default: 1)
target: "*" # "triggering" (default), "*", or number
target-repo: "owner/repo" # cross-repository
Expand Down
Loading