Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion setup/js/check_command_position.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ async function main() {
core.setOutput("matched_command", "");
await writeDenialSummary(
`The trigger comment did not start with a required command. Expected one of: ${expectedCommands}. Found: \`${firstWord}\`.`,
"Make sure the trigger comment starts with the required command defined in `on.command:` in the workflow frontmatter."
"Make sure the trigger comment starts with the required command defined in `on.slash_command:` in the workflow frontmatter."
);
}
} catch (error) {
Expand Down
15 changes: 11 additions & 4 deletions setup/js/generate_footer.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { getDetectionReasonText, getThreatDetectedMarker } = require("./threat_detection_warning.cjs");
const { getDetectionReasonText, getThreatDetectedMarker, isToolingFailureReason } = require("./threat_detection_warning.cjs");

/**
* Generates a standalone workflow-id XML comment marker for searchability.
Expand Down Expand Up @@ -105,9 +105,13 @@ function generateXMLMarker(workflowName, runUrl) {
}

/**
* Get the detection caution alert for expired entity closing comments.
* Get the detection alert for expired entity closing comments.
* Reads GH_AW_DETECTION_CONCLUSION and GH_AW_DETECTION_REASON from environment variables.
* Returns the caution alert markdown when conclusion is "warning", or empty string otherwise.
* Returns alert markdown when conclusion is "warning", or empty string otherwise.
*
* When the reason indicates a tooling failure (agent_failure or parse_error) a [!WARNING]
* admonition is used so reviewers can distinguish "detection engine crashed" from "detection
* engine found something". Actual threat findings (threat_detected) keep [!CAUTION].
*
* Note: This function is intentionally kept inline (not imported from messages_footer.cjs)
* because importing messages_footer.cjs here would cause the bundler to inline
Expand All @@ -119,7 +123,7 @@ function generateXMLMarker(workflowName, runUrl) {
*
* @param {string} workflowName - Name of the workflow
* @param {string} runUrl - URL of the workflow run
* @returns {string} Caution alert markdown or empty string
* @returns {string} Alert markdown or empty string
*/
function getExpiredEntityCautionAlert(workflowName, runUrl) {
const detectionConclusion = process.env.GH_AW_DETECTION_CONCLUSION;
Expand All @@ -128,6 +132,9 @@ function getExpiredEntityCautionAlert(workflowName, runUrl) {
}
const detectionReason = process.env.GH_AW_DETECTION_REASON || "";
const reasonText = getDetectionReasonText(detectionReason);
if (isToolingFailureReason(detectionReason)) {
return `> [!WARNING]\n> threat detection engine error\n> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.\n> ${getThreatDetectedMarker(detectionReason)}\n>\n> <details>\n> <summary>Details</summary>\n>\n> ${reasonText}\n>\n> Review the [workflow run logs](${runUrl}) for details.\n> </details>`;
}
return `> [!CAUTION]\n> agentic threat detected\n> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.\n> ${getThreatDetectedMarker(detectionReason)}\n>\n> <details>\n> <summary>Details</summary>\n>\n> ${reasonText}\n>\n> Review the [workflow run logs](${runUrl}) for details.\n> </details>`;
}

Expand Down
15 changes: 8 additions & 7 deletions setup/js/install_frontmatter_skills.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,18 @@ function appendSkillInstallFailure(skillSpec, errorMessage) {
* @returns {Promise<void>}
*/
async function writeSkillSummary(skillDir, skills, installedSkillCount, failures) {
core.summary
.addRaw("### Frontmatter skills installed\n\n")
.addRaw(`- Engine skill directory: \`${skillDir}\`\n`)
.addRaw(`- Requested references: \`${JSON.stringify(skills)}\`\n`)
.addRaw(`- Installed SKILL.md files: ${installedSkillCount}\n`);
let body = "";
body += `- Engine skill directory: \`${skillDir}\`\n`;
body += `- Requested references: \`${JSON.stringify(skills)}\`\n`;
body += `- Installed SKILL.md files: ${installedSkillCount}\n`;
if (failures.length > 0) {
core.summary.addRaw("\n#### ⚠️ Skill install failures\n\n");
body += "\n#### Skill install failures\n\n";
for (const f of failures) {
core.summary.addRaw(`- \`${f.skill}\`: ${f.error}\n`);
body += `- \`${f.skill}\`: ${f.error}\n`;
}
}
const openAttr = failures.length > 0 ? " open" : "";
core.summary.addRaw(`### Frontmatter skills installed\n\n<details${openAttr}>\n<summary>Skill install details</summary>\n\n${body}\n</details>\n\n`);
await core.summary.write();
}

Expand Down
34 changes: 33 additions & 1 deletion setup/js/messages_footer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ function buildAICEntry(label, value, modelAlias) {
* aiCredits: number|undefined,
* aiCreditsFormatted: string|undefined,
* aiCreditsSuffix: string,
* aiModel: string|undefined,
* aiModelShort: string|undefined,
* compressedModelName: string|undefined,
* agentAiCredits: number|undefined,
* agentAiCreditsFormatted: string|undefined,
Expand All @@ -110,7 +112,8 @@ function buildAICEntry(label, value, modelAlias) {
* }}
*/
function getAICFromEnv() {
const compressedModelName = reduceModelNameToIdentifier(process.env.GH_AW_PRIMARY_MODEL || process.env.GH_AW_ENGINE_MODEL);
const aiModel = process.env.GH_AW_PRIMARY_MODEL || process.env.GH_AW_ENGINE_MODEL || undefined;
const compressedModelName = reduceModelNameToIdentifier(aiModel);
const totalAIC = parsePositiveAIC(process.env.GH_AW_AIC);
const explicitAgentAIC = parsePositiveAIC(process.env.GH_AW_AGENT_AIC);
const evalsAIC = parsePositiveAIC(process.env.GH_AW_EVALS_AIC);
Expand All @@ -128,6 +131,8 @@ function getAICFromEnv() {
aiCredits,
aiCreditsFormatted,
aiCreditsSuffix,
aiModel,
aiModelShort: compressedModelName,
compressedModelName,
agentAiCredits: agentEntry.value,
agentAiCreditsFormatted: agentEntry.formatted,
Expand Down Expand Up @@ -171,6 +176,8 @@ function getFooterMessage(ctx) {
aiCredits: envAIC,
aiCreditsFormatted: envAICFormatted,
aiCreditsSuffix: envAICSuffix,
aiModel,
aiModelShort,
compressedModelName,
agentAiCredits,
agentAiCreditsFormatted,
Expand All @@ -185,6 +192,8 @@ function getFooterMessage(ctx) {
const { ambientContext: envAmbientContext, ambientContextFormatted: envAmbientContextFormatted, ambientContextSuffix: envAmbientContextSuffix } = getAmbientContextFromEnv();
const aiCredits = ctx.aiCredits ?? envAIC;
const ambientContext = envAmbientContext;
const detectionConclusion = process.env.GH_AW_DETECTION_CONCLUSION || undefined;
const detectionReason = process.env.GH_AW_DETECTION_REASON || undefined;

// Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable)
const historyLink = ctx.historyUrl ? ` · [◷](${ctx.historyUrl})` : "";
Expand All @@ -210,6 +219,11 @@ function getFooterMessage(ctx) {
agenticWorkflowUrl,
aiCreditsFormatted,
aiCreditsSuffix: aiCreditsSuffixForTemplate,
aiModel,
aiModelShort,
aiCreditsUnit: "AIC",
detectionConclusion,
detectionReason,
ambientContext,
ambientContextFormatted: envAmbientContextFormatted,
ambientContextSuffix: envAmbientContextSuffix,
Expand Down Expand Up @@ -397,6 +411,8 @@ function getFooterAgentFailureIssueMessage(ctx) {
aiCredits: envAIC,
aiCreditsFormatted: envAICFormatted,
aiCreditsSuffix: envAICSuffix,
aiModel,
aiModelShort,
compressedModelName,
agentAiCredits,
agentAiCreditsFormatted,
Expand All @@ -415,6 +431,8 @@ function getFooterAgentFailureIssueMessage(ctx) {
const aiCreditsFormatted = hasExplicitContextAIC ? (explicitContextAIC ? formatAIC(explicitContextAIC) : undefined) : envAICFormatted;
const aiCreditsSuffix = hasExplicitContextAIC ? buildAICEntry("", explicitContextAIC, compressedModelName).suffix : envAICSuffix;
const aiCreditsSuffixForTemplate = `${aiCreditsSuffix}${ambientContextSuffix}`;
const detectionConclusion = process.env.GH_AW_DETECTION_CONCLUSION || undefined;
const detectionReason = process.env.GH_AW_DETECTION_REASON || undefined;

// Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url
const templateContext = toSnakeCase({
Expand All @@ -424,6 +442,11 @@ function getFooterAgentFailureIssueMessage(ctx) {
aiCredits,
aiCreditsFormatted,
aiCreditsSuffix: aiCreditsSuffixForTemplate,
aiModel,
aiModelShort,
aiCreditsUnit: "AIC",
detectionConclusion,
detectionReason,
agentAiCredits,
agentAiCreditsFormatted,
agentAiCreditsSuffix,
Expand Down Expand Up @@ -479,6 +502,8 @@ function getFooterAgentFailureCommentMessage(ctx) {
aiCredits: envAIC,
aiCreditsFormatted: envAICFormatted,
aiCreditsSuffix: envAICSuffix,
aiModel,
aiModelShort,
compressedModelName,
agentAiCredits,
agentAiCreditsFormatted,
Expand All @@ -497,6 +522,8 @@ function getFooterAgentFailureCommentMessage(ctx) {
const aiCreditsFormatted = hasExplicitContextAIC ? (explicitContextAIC ? formatAIC(explicitContextAIC) : undefined) : envAICFormatted;
const aiCreditsSuffix = hasExplicitContextAIC ? buildAICEntry("", explicitContextAIC, compressedModelName).suffix : envAICSuffix;
const aiCreditsSuffixForTemplate = `${aiCreditsSuffix}${ambientContextSuffix}`;
const detectionConclusion = process.env.GH_AW_DETECTION_CONCLUSION || undefined;
const detectionReason = process.env.GH_AW_DETECTION_REASON || undefined;

// Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url
const templateContext = toSnakeCase({
Expand All @@ -506,6 +533,11 @@ function getFooterAgentFailureCommentMessage(ctx) {
aiCredits,
aiCreditsFormatted,
aiCreditsSuffix: aiCreditsSuffixForTemplate,
aiModel,
aiModelShort,
aiCreditsUnit: "AIC",
detectionConclusion,
detectionReason,
agentAiCredits,
agentAiCreditsFormatted,
agentAiCreditsSuffix,
Expand Down
15 changes: 13 additions & 2 deletions setup/js/messages_run_status.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cjs");
const { getDetectionReasonText, getThreatDetectedMarkerTemplate, normalizeThreatKinds } = require("./threat_detection_warning.cjs");
const { getDetectionReasonText, getThreatDetectedMarkerTemplate, normalizeThreatKinds, isToolingFailureReason } = require("./threat_detection_warning.cjs");

/**
* Renders a message using a custom template from config or a default template.
Expand Down Expand Up @@ -141,11 +141,22 @@ function getCommitPushedMessage(ctx) {
/**
* Get the detection-warning message with progressive disclosure via details/summary.
* Used when continue-on-error is true (default) instead of false.
*
* When the reason indicates a tooling failure (agent_failure or parse_error) the
* message uses a [!WARNING] admonition so reviewers can distinguish "detection
* engine crashed" from "detection engine found something". Actual threat findings
* (threat_detected) keep the [!CAUTION] admonition.
*
* @param {DetectionWarningContext} ctx - Context for detection-warning message generation
* @returns {string} Detection-warning message with caution admonition
* @returns {string} Detection-warning message with admonition
*/
function getDetectionWarningMessage(ctx) {
const reasonText = getDetectionReasonText(ctx.reason);
const isEngineError = isToolingFailureReason(ctx.reason);
if (isEngineError) {
const defaultTemplate = `> [!WARNING]\n> threat detection engine error\n> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.\n> ${getThreatDetectedMarkerTemplate()}\n>\n> <details>\n> <summary>Details</summary>\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n> </details>`;
return renderConfiguredMessage("detectionEngineError", defaultTemplate, { ...ctx, reasonText, threatKinds: normalizeThreatKinds(ctx.reason) });
Comment on lines +157 to +158
}
const defaultTemplate = `> [!CAUTION]\n> agentic threat detected\n> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.\n> ${getThreatDetectedMarkerTemplate()}\n>\n> <details>\n> <summary>Details</summary>\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n> </details>`;
return renderConfiguredMessage("detectionWarning", defaultTemplate, { ...ctx, reasonText, threatKinds: normalizeThreatKinds(ctx.reason) });
}
Expand Down
9 changes: 8 additions & 1 deletion setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1084,7 +1084,14 @@ function createHandlers(server, appendSafeOutput, config = {}) {
server.debug(`Using configured patch_workspace_path for push_to_pull_request_branch: ${pushPatchWorkspacePath} -> ${repoCwd}`);
}

if (((entry.repo && entry.repo.trim()) || pushConfig["target-repo"]) && !repoCwd) {
const envTargetSlug = (process.env.GH_AW_TARGET_REPO_SLUG || "").trim();
const currentRepo = (process.env.GITHUB_REPOSITORY || "").toLowerCase();
const envSlugIsSideRepo = envTargetSlug && envTargetSlug.toLowerCase() !== currentRepo;
if (envTargetSlug && !envSlugIsSideRepo) {
server.debug(`GH_AW_TARGET_REPO_SLUG (${envTargetSlug}) matches current repo; not using as side-repo checkout hint for push_to_pull_request_branch`);
}
const hasExplicitTargetRepoHint = (entry.repo && entry.repo.trim()) || pushConfig["target-repo"] || envSlugIsSideRepo;
if (hasExplicitTargetRepoHint && !repoCwd) {
server.debug(`Looking for checkout of target repo: ${itemRepo}`);
const checkoutResult = findRepoCheckout(itemRepo);
if (!checkoutResult.success) {
Expand Down
15 changes: 15 additions & 0 deletions setup/js/threat_detection_warning.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,24 @@ function getDetectionReasonText(reason) {
return reasonDescriptions[normalizedReason] || "The threat detection analysis could not be completed.";
}

/**
* Returns true when the reason indicates a tooling failure rather than an actual
* security finding. Tooling failures (agent_failure, parse_error) mean the
* detection engine itself crashed or could not produce a verdict — they should be
* surfaced as a distinct infrastructure error, not as a security threat.
*
* @param {string | undefined | null} reason
* @returns {boolean}
*/
function isToolingFailureReason(reason) {
const normalized = String(reason || "").trim();
return normalized === "agent_failure" || normalized === "parse_error";
}

module.exports = {
normalizeThreatKinds,
getThreatDetectedMarker,
getThreatDetectedMarkerTemplate,
getDetectionReasonText,
isToolingFailureReason,
};
11 changes: 9 additions & 2 deletions setup/js/update_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ function isNonFatalUpdateBranchError(error) {
// Require both permission wording and update-branch context to avoid treating unrelated
// "workflows permission" errors as non-fatal for pull request branch updates.
const hasWorkflowsPermissionError = hasWorkflowsPermissionPhrase && (hasWorkflowMutationRefusal || message.includes("update pull request"));
// GitHub update-branch API also returns 403 with this message when a PR contains workflow
// file changes and the check times out, rather than the usual "refusing to allow" phrase.
const hasWorkflowsScopeRequired = message.includes("`workflows` scope may be required") || message.includes("unable to determine if workflow can be created or updated");

if (status !== undefined) {
if (status === 403 && hasWorkflowsPermissionError) {
if (status === 403 && (hasWorkflowsPermissionError || hasWorkflowsScopeRequired)) {
return true;
}
if (status !== 422) {
Expand All @@ -52,7 +55,11 @@ function isNonFatalUpdateBranchError(error) {
// - already up to date ("There are no new commits on the base branch")
// - cannot auto-update due to conflict ("merge conflict between base and head")
// These should not fail safe output processing.
return message.includes("there are no new commits on the base branch") || message.includes("merge conflict between base and head") || hasWorkflowsPermissionError;
// hasWorkflowsPermissionError / hasWorkflowsScopeRequired are only checked here for errors
// with no numeric status (status === undefined). The explicit 403 case is already handled
// by the if-block above, and other numeric statuses (e.g. 422 with these phrases) should
// not be silently swallowed.
return message.includes("there are no new commits on the base branch") || message.includes("merge conflict between base and head") || ((hasWorkflowsPermissionError || hasWorkflowsScopeRequired) && status === undefined);
}

/**
Expand Down
9 changes: 9 additions & 0 deletions setup/md/mcp_cli_tools_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ printf '{"item_number":42,"body":"### Title\n\nBody."}' | safeoutputs add_commen
# or write to a file: safeoutputs create_pull_request . < /tmp/payload.json
```

To inject an entire local file as the `body` field without re-embedding its content in the model context, use `jq -Rs`:
```bash
jq -Rs --arg discussion_number "$DISCUSSION_NUMBER" \
'{discussion_number: ($discussion_number|tonumber), body: .}' \
discussion-body.md \
| safeoutputs update_discussion .
```
`jq -Rs` reads the file as a raw string (`-R`) and slurps it into a single JSON string value (`-s`), so `body` is always a valid JSON field. Piping `cat file | safeoutputs ...` does not populate `body` and will be rejected.

The generated command syntax above is schema-derived from each enabled tool's final `inputSchema` and is the source of truth for required/optional parameters.
Use `<server> --help` and `<server> <tool> --help` for the same schema-derived signatures and examples before calling any command.
</mcp-clis>
2 changes: 1 addition & 1 deletion setup/md/safe_outputs_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ Safe-output calls are write-once declarations for real downstream side effects.

temporary_id: optional cross-reference field for future resources created by safe outputs. Canonical form: '#aw_' followed by 3–12 alphanumeric or underscore characters — e.g., '#aw_abc1', '#aw_pr_fix'. Pattern: /^#?aw_[A-Za-z0-9_]{3,12}$/i (the '#' prefix is optional; bare 'aw_abc1' is accepted and normalised to '#aw_abc1' automatically). Use this form for all field values (temporary_id, item_number, issue_number, parent, etc.). In body/markdown text, '#aw_abc1' references are replaced with the real issue/PR number after creation. Omit entirely when not needed.

**Note**: safeoutputs tools do NOT support `@filename` file name expansion. Always provide content inline — do not use `@filename` references in tool arguments.
**Note**: safeoutputs tools do NOT support `@filename` file name expansion. Always provide content inline — do not use `@filename` references in tool arguments. To inject an entire file as the `body` field, use `jq -Rs` to read it as a JSON string and pipe the resulting payload: `jq -Rs '{body: .}' file.md | safeoutputs update_discussion .`
</safe-outputs>
Loading