Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b3565d0
Initial plan
Copilot Mar 19, 2026
ce8b3ef
feat: add safe-output actions support for mounting custom GitHub Acti…
Copilot Mar 19, 2026
b550238
fix: address code review comments - fix base64 decoding and use INTER…
Copilot Mar 19, 2026
1b7f9bb
feat: add env field per action, redact strings with sanitizeContent, …
Copilot Mar 19, 2026
44a547d
feat: add add-smoked-label action to smoke-codex, skip ${{ inputs fro…
Copilot Mar 19, 2026
df8c250
merge: merge origin/main
Copilot Mar 19, 2026
84df56f
fix: address code review - resolve actions early, use pinned SHA for …
Copilot Mar 19, 2026
6e6b35e
Merge branch 'main' into copilot/add-support-safe-output-tools
pelikhan Mar 19, 2026
398b143
fix: update MessageHandlerFunction type to accept optional temporaryI…
Copilot Mar 19, 2026
6a9c487
Merge branch 'main' into copilot/add-support-safe-output-tools
pelikhan Mar 19, 2026
ead4e55
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot Mar 19, 2026
bdc20c1
chore: recompile workflows
Copilot Mar 19, 2026
7a554f9
Add changeset [skip-ci]
github-actions[bot] Mar 19, 2026
67d4dd5
fix: add continue-on-error to Upload safe output items step to preven…
Copilot Mar 19, 2026
29c9973
Potential fix for pull request finding
pelikhan Mar 19, 2026
5aad3d9
Merge branch 'main' into copilot/add-support-safe-output-tools
pelikhan Mar 19, 2026
b3559c9
fix: remove stale resolveAllActions() call, add action handler tests,…
Copilot Mar 19, 2026
8cc1b77
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot Mar 19, 2026
a588b34
test: add integration tests for safe-outputs.actions compilation
Copilot Mar 19, 2026
ec1e666
Merge remote-tracking branch 'origin/main' into copilot/add-support-s…
Copilot Mar 19, 2026
31250a1
chore: merge main and recompile
Copilot Mar 19, 2026
f2ee097
Merge branch 'main' into copilot/add-support-safe-output-tools
pelikhan Mar 19, 2026
de5f3ce
fix: remove unrelated continue-on-error change from safe output uploa…
Copilot Mar 19, 2026
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
5 changes: 5 additions & 0 deletions .changeset/patch-add-safe-output-actions.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/aw/actions-lock.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"entries": {
"actions-ecosystem/action-add-labels@v1": {
Copy link
Contributor

Choose a reason for hiding this comment

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

New action pin entry added. Consider verifying the SHA corresponds to the correct version tag to ensure reproducibility and security.

"repo": "actions-ecosystem/action-add-labels",
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider adding a comment explaining the sha field here — it's great for security pinning, but a brief note about how/when these SHAs get updated would help contributors understand the maintenance process for this lock file.

"version": "v1",
"sha": "18f1af5e3544586314bbe15c0273249c770b2daf"
},
"actions/ai-inference@v2.0.7": {
"repo": "actions/ai-inference",
"version": "v2.0.7",
Expand Down
28 changes: 26 additions & 2 deletions .github/workflows/smoke-codex.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions .github/workflows/smoke-codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ safe-outputs:
run-started: "🔮 The ancient spirits stir... [{workflow_name}]({run_url}) awakens to divine this {event_type}..."
run-success: "✨ The prophecy is fulfilled... [{workflow_name}]({run_url}) has completed its mystical journey. The stars align. 🌟"
run-failure: "🌑 The shadows whisper... [{workflow_name}]({run_url}) {status}. The oracle requires further meditation..."
actions:
add-smoked-label:
uses: actions-ecosystem/action-add-labels@v1
description: Add the 'smoked' label to the current pull request
env:
GITHUB_TOKEN: ${{ github.token }}
timeout-minutes: 15
checkout:
- fetch-depth: 2
Expand Down Expand Up @@ -99,6 +105,7 @@ If all tests pass:
- Use the `add_labels` safe-output tool to add the label `smoke-codex` to the pull request
- Use the `remove_labels` safe-output tool to remove the label `smoke` from the pull request
- Use the `unassign_from_user` safe-output tool to unassign the user `githubactionagent` from the pull request (this is a fictitious user used for testing)
- Use the `add_smoked_label` safe-output action tool to add the label `smoked` to the pull request (call it with `{"labels": "smoked"}`)

**Important**: If no action is needed after completing your analysis, you **MUST** call the `noop` safe-output tool with a brief explanation. Failing to call any safe-output tool is the most common cause of safe-output workflow failures.

Expand Down
119 changes: 119 additions & 0 deletions actions/setup/js/safe_output_action_handler.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// @ts-check
Copy link
Contributor

Choose a reason for hiding this comment

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

Smoke test review comment #2: New JS handler file — consider adding JSDoc comments at the top for IDE tooling support.

/// <reference types="@actions/github-script" />

/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
*/

const { replaceTemporaryIdReferences } = require("./temporary_id.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

/**
* Internal safe-output message fields that should not be forwarded as action inputs.
* These fields are part of the safe-output messaging protocol and are not user-defined.
* Maintained as an explicit set so future protocol fields can be added without silently
* forwarding them to external action `with:` inputs.
* @type {Set<string>}
*/
const INTERNAL_MESSAGE_FIELDS = new Set(["type"]);

/**
* Main handler factory for a custom safe output action.
*
* Each configured safe-output action gets its own instance of this factory function,
* invoked with a config that includes `action_name` (the normalized tool name).
*
* The handler:
* 1. Enforces that the action is called at most once (per the spec).
* 2. Applies temporary ID substitutions to all string-valued fields in the payload.
* 3. Exports the processed payload as a step output named `action_<name>_payload`.
*
* The compiler generates a corresponding GitHub Actions step with:
* if: steps.process_safe_outputs.outputs.action_<name>_payload != ''
* uses: <resolved-action-ref>
* with:
* <input>: ${{ fromJSON(steps.process_safe_outputs.outputs.action_<name>_payload).<input> }}
*
* @type {HandlerFactoryFunction}
*/
async function main(config = {}) {
const actionName = config.action_name || "unknown_action";
const outputKey = `action_${actionName}_payload`;

core.info(`Custom action handler initialized: action_name=${actionName}, output_key=${outputKey}`);

// Track whether this action has been called (enforces once-only constraint)
let called = false;

/**
* Handler function that processes a single tool call for this action.
* Applies temporary ID substitutions and exports the payload as a step output.
*
* @param {Object} message - The tool call message from the agent output
* @param {Object} resolvedTemporaryIds - Map of temp IDs to resolved values (plain object)
* @param {Map<string, Object>} temporaryIdMap - Live map of temporary IDs (for substitution)
* @returns {Promise<Object>} Result with success/error status
*/
return async function handleCustomAction(message, resolvedTemporaryIds, temporaryIdMap = new Map()) {
// Enforce once-only constraint
if (called) {
const error = `Action "${actionName}" can only be called once per workflow run`;
core.warning(error);
return {
success: false,
error,
};
}
called = true;

try {
core.info(`Processing custom action: ${actionName}`);

// Build the processed payload by:
// 1. Applying temporary ID reference substitutions to string fields
// 2. Redacting (sanitizing) all string fields via sanitizeContent() before export.
// This prevents prompt-injected content from leaking URLs, mentions, or harmful
// content into external action inputs.
const processedInputs = {};
for (const [key, value] of Object.entries(message)) {
// Skip internal safe-output messaging fields that are not action inputs.
// Maintained as an explicit set to allow future additions without silently
// forwarding new internal fields to external action steps.
if (INTERNAL_MESSAGE_FIELDS.has(key)) {
continue;
}

if (typeof value === "string") {
// Apply temporary ID reference substitution (e.g., "aw_abc1" → "42"), then
// sanitize to redact malicious URLs, neutralize bot-trigger phrases, and
// escape @mentions that could cause unintended notifications in the action.
const substituted = replaceTemporaryIdReferences(value, temporaryIdMap);
processedInputs[key] = sanitizeContent(substituted);
} else {
processedInputs[key] = value;
}
}

// Export the processed payload as a step output
const payloadJSON = JSON.stringify(processedInputs);
core.setOutput(outputKey, payloadJSON);
core.info(`✓ Custom action "${actionName}": exported payload as "${outputKey}" (${payloadJSON.length} bytes)`);

return {
success: true,
action_name: actionName,
payload: payloadJSON,
};
} catch (error) {
const errorMessage = getErrorMessage(error);
core.error(`Failed to process custom action "${actionName}": ${errorMessage}`);
return {
success: false,
error: `Failed to process custom action "${actionName}": ${errorMessage}`,
};
}
};
}

module.exports = { main };
117 changes: 117 additions & 0 deletions actions/setup/js/safe_output_action_handler.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @ts-check

import { describe, it, expect, beforeEach, vi } from "vitest";
import { main } from "./safe_output_action_handler.cjs";

describe("safe_output_action_handler", () => {
beforeEach(() => {
// Provide a mock global `core` matching the @actions/core API surface used by the handler.
global.core = {
info: vi.fn(),
debug: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
setOutput: vi.fn(),
setFailed: vi.fn(),
};
});

describe("main() — factory", () => {
it("should return a function (the handler) when called", async () => {
const handler = await main({ action_name: "my_action" });
expect(typeof handler).toBe("function");
});

it("should use 'unknown_action' when config is not provided", async () => {
const handler = await main();
expect(typeof handler).toBe("function");
});
});

describe("handler — basic payload export", () => {
it("should export the payload as a step output and return success", async () => {
const handler = await main({ action_name: "add_label" });

const message = { type: "add_label", labels: "bug" };
const result = await handler(message, {}, new Map());

expect(result.success).toBe(true);
expect(result.action_name).toBe("add_label");

// 'type' (an INTERNAL_MESSAGE_FIELDS member) must be stripped from the exported payload
const exported = JSON.parse(global.core.setOutput.mock.calls[0][1]);
expect(exported).toHaveProperty("labels", "bug");
expect(exported).not.toHaveProperty("type");
});

it("should use the correct output key format action_<name>_payload", async () => {
const handler = await main({ action_name: "my_action" });
await handler({ type: "my_action", key: "value" }, {}, new Map());

expect(global.core.setOutput).toHaveBeenCalledWith("action_my_action_payload", expect.any(String));
});

it("should pass through non-string values without sanitization", async () => {
const handler = await main({ action_name: "my_action" });
const message = { type: "my_action", count: 42, active: true };

await handler(message, {}, new Map());

const exported = JSON.parse(global.core.setOutput.mock.calls[0][1]);
expect(exported.count).toBe(42);
expect(exported.active).toBe(true);
});
});

describe("handler — once-only enforcement", () => {
it("should return an error on the second call", async () => {
const handler = await main({ action_name: "my_action" });
const message = { labels: "bug" };

const first = await handler(message, {}, new Map());
expect(first.success).toBe(true);

const second = await handler(message, {}, new Map());
expect(second.success).toBe(false);
expect(second.error).toContain("can only be called once");

// setOutput should only be called once (for the first invocation)
expect(global.core.setOutput).toHaveBeenCalledTimes(1);
});
});

describe("handler — INTERNAL_MESSAGE_FIELDS filtering", () => {
it("should strip 'type' from the exported payload", async () => {
const handler = await main({ action_name: "my_action" });
await handler({ type: "my_action", title: "hello" }, {}, new Map());

const exported = JSON.parse(global.core.setOutput.mock.calls[0][1]);
expect(exported).not.toHaveProperty("type");
expect(exported).toHaveProperty("title", "hello");
});
});

describe("handler — temporaryIdMap substitution", () => {
it("should substitute temporary ID references in string values", async () => {
const handler = await main({ action_name: "my_action" });
// The temporary ID pattern is "#aw_XXXX" (3-12 alphanumeric chars with #aw_ prefix)
const temporaryIdMap = new Map([["aw_abc1", { repo: "owner/repo", number: 99 }]]);
await handler({ type: "my_action", body: "Fixes #aw_abc1" }, {}, temporaryIdMap);

const exported = JSON.parse(global.core.setOutput.mock.calls[0][1]);
// replaceTemporaryIdReferences should replace '#aw_abc1' with the issue reference
expect(exported.body).not.toContain("#aw_abc1");
});
});

describe("handler — empty payload", () => {
it("should handle an empty message (only internal fields)", async () => {
const handler = await main({ action_name: "my_action" });
const result = await handler({ type: "my_action" }, {}, new Map());

expect(result.success).toBe(true);
const exported = JSON.parse(global.core.setOutput.mock.calls[0][1]);
expect(exported).toEqual({});
});
});
});
31 changes: 30 additions & 1 deletion actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const { getIssuesToAssignCopilot } = require("./create_issue.cjs");
const { createReviewBuffer } = require("./pr_review_buffer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { createManifestLogger, ensureManifestExists, extractCreatedItemFromResult } = require("./safe_output_manifest.cjs");
const { loadCustomSafeOutputJobTypes, loadCustomSafeOutputScriptHandlers } = require("./safe_output_helpers.cjs");
const { loadCustomSafeOutputJobTypes, loadCustomSafeOutputScriptHandlers, loadCustomSafeOutputActionHandlers } = require("./safe_output_helpers.cjs");
const { emitSafeOutputActionOutputs } = require("./safe_outputs_action_outputs.cjs");

/**
Expand Down Expand Up @@ -194,6 +194,35 @@ async function loadHandlers(config, prReviewBuffer) {
}
}

// Load custom action handlers from GH_AW_SAFE_OUTPUT_ACTIONS
// These are GitHub Actions configured in safe-outputs.actions. The handler applies
// temporary ID substitutions to the payload and exports `action_<name>_payload` outputs
// that compiler-generated `uses:` steps consume.
const customActionHandlers = loadCustomSafeOutputActionHandlers();
if (customActionHandlers.size > 0) {
core.info(`Loading ${customActionHandlers.size} custom action handler(s): ${[...customActionHandlers.keys()].join(", ")}`);
const actionHandlerPath = require("path").join(__dirname, "safe_output_action_handler.cjs");
for (const [actionType, actionName] of customActionHandlers) {
try {
const actionModule = require(actionHandlerPath);
if (actionModule && typeof actionModule.main === "function") {
const handlerConfig = { action_name: actionName, ...(config[actionType] || {}) };
const messageHandler = await actionModule.main(handlerConfig);
if (typeof messageHandler !== "function") {
core.warning(`✗ Custom action handler ${actionType} main() did not return a function (got ${typeof messageHandler}) — this handler will be skipped`);
} else {
messageHandlers.set(actionType, messageHandler);
core.info(`✓ Loaded and initialized custom action handler for: ${actionType}`);
}
} else {
core.warning(`Custom action handler module does not export a main function — skipping ${actionType}`);
}
} catch (error) {
core.warning(`Failed to load custom action handler for ${actionType}: ${getErrorMessage(error)} — this handler will be skipped`);
}
}
}

core.info(`Loaded ${messageHandlers.size} handler(s)`);
return messageHandlers;
}
Expand Down
Loading