Skip to content

Dev

Dev #10

Workflow file for this run

# This file was automatically generated by gh-aw. DO NOT EDIT.
# To update this file, edit the corresponding .md file and run:
# gh aw compile
name: "Dev"
on:
push:
branches:
- copilot/*
- pelikhan/*
workflow_dispatch: null
permissions: {}
concurrency:
group: gh-aw-${{ github.workflow }}-${{ github.ref }}
run-name: "Dev"
jobs:
task:
runs-on: ubuntu-latest
permissions:
actions: write # Required for github.rest.actions.cancelWorkflowRun()
steps:
- name: Check team membership for workflow
id: check-team-member
uses: actions/github-script@v7
env:
GITHUB_AW_REQUIRED_ROLES: admin,maintainer
with:
script: |
async function setCancelled(message) {
try {
await github.rest.actions.cancelWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.runId,
});
core.info(`Cancellation requested for this workflow run: ${message}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
core.warning(`Failed to cancel workflow run: ${errorMessage}`);
core.setFailed(message); // Fallback if API call fails
}
}
async function main() {
const { eventName } = context;
// skip check for safe events
const safeEvents = ["workflow_dispatch", "workflow_run", "schedule"];
if (safeEvents.includes(eventName)) {
core.info(`✅ Event ${eventName} does not require validation`);
return;
}
const actor = context.actor;
const { owner, repo } = context.repo;
const requiredPermissionsEnv = process.env.GITHUB_AW_REQUIRED_ROLES;
const requiredPermissions = requiredPermissionsEnv
? requiredPermissionsEnv.split(",").filter(p => p.trim() !== "")
: [];
if (!requiredPermissions || requiredPermissions.length === 0) {
core.error(
"❌ Configuration error: Required permissions not specified. Contact repository administrator."
);
await setCancelled(
"Configuration error: Required permissions not specified"
);
return;
}
// Check if the actor has the required repository permissions
try {
core.debug(
`Checking if user '${actor}' has required permissions for ${owner}/${repo}`
);
core.debug(`Required permissions: ${requiredPermissions.join(", ")}`);
const repoPermission =
await github.rest.repos.getCollaboratorPermissionLevel({
owner: owner,
repo: repo,
username: actor,
});
const permission = repoPermission.data.permission;
core.debug(`Repository permission level: ${permission}`);
// Check if user has one of the required permission levels
for (const requiredPerm of requiredPermissions) {
if (
permission === requiredPerm ||
(requiredPerm === "maintainer" && permission === "maintain")
) {
core.info(`✅ User has ${permission} access to repository`);
return;
}
}
core.warning(
`User permission '${permission}' does not meet requirements: ${requiredPermissions.join(", ")}`
);
} catch (repoError) {
const errorMessage =
repoError instanceof Error ? repoError.message : String(repoError);
core.error(`Repository permission check failed: ${errorMessage}`);
await setCancelled(`Repository permission check failed: ${errorMessage}`);
return;
}
// Cancel the workflow when permission check fails
core.warning(
`❌ Access denied: Only authorized users can trigger this workflow. User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}`
);
await setCancelled(
`Access denied: User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}`
);
}
await main();
dev:
needs: task
runs-on: ubuntu-latest
permissions: read-all
outputs:
output: ${{ steps.collect_output.outputs.output }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Generate Claude Settings
run: |
mkdir -p /tmp/.claude
cat > /tmp/.claude/settings.json << 'EOF'
{
"hooks": {
"PreToolUse": [
{
"matcher": "WebFetch|WebSearch",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/network_permissions.py"
}
]
}
]
}
}
EOF
- name: Generate Network Permissions Hook
run: |
mkdir -p .claude/hooks
cat > .claude/hooks/network_permissions.py << 'EOF'
#!/usr/bin/env python3
"""
Network permissions validator for Claude Code engine.
Generated by gh-aw from engine network permissions configuration.
"""
import json
import sys
import urllib.parse
import re
# Domain allow-list (populated during generation)
ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","packagecloud.io","packages.cloud.google.com","packages.microsoft.com"]
def extract_domain(url_or_query):
"""Extract domain from URL or search query."""
if not url_or_query:
return None
if url_or_query.startswith(('http://', 'https://')):
return urllib.parse.urlparse(url_or_query).netloc.lower()
# Check for domain patterns in search queries
match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query)
if match:
return match.group(1).lower()
return None
def is_domain_allowed(domain):
"""Check if domain is allowed."""
if not domain:
# If no domain detected, allow only if not under deny-all policy
return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains
# Empty allowed domains means deny all
if not ALLOWED_DOMAINS:
return False
for pattern in ALLOWED_DOMAINS:
regex = pattern.replace('.', r'\.').replace('*', '.*')
if re.match(f'^{regex}$', domain):
return True
return False
# Main logic
try:
data = json.load(sys.stdin)
tool_name = data.get('tool_name', '')
tool_input = data.get('tool_input', {})
if tool_name not in ['WebFetch', 'WebSearch']:
sys.exit(0) # Allow other tools
target = tool_input.get('url') or tool_input.get('query', '')
domain = extract_domain(target)
# For WebSearch, apply domain restrictions consistently
# If no domain detected in search query, check if restrictions are in place
if tool_name == 'WebSearch' and not domain:
# Since this hook is only generated when network permissions are configured,
# empty ALLOWED_DOMAINS means deny-all policy
if not ALLOWED_DOMAINS: # Empty list means deny all
print(f"Network access blocked: deny-all policy in effect", file=sys.stderr)
print(f"No domains are allowed for WebSearch", file=sys.stderr)
sys.exit(2) # Block under deny-all policy
else:
print(f"Network access blocked for web-search: no specific domain detected", file=sys.stderr)
print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr)
sys.exit(2) # Block general searches when domain allowlist is configured
if not is_domain_allowed(domain):
print(f"Network access blocked for domain: {domain}", file=sys.stderr)
print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr)
sys.exit(2) # Block with feedback to Claude
sys.exit(0) # Allow
except Exception as e:
print(f"Network validation error: {e}", file=sys.stderr)
sys.exit(2) # Block on errors
EOF
chmod +x .claude/hooks/network_permissions.py
- name: Setup agent output
id: setup_agent_output
uses: actions/github-script@v7
with:
script: |
function main() {
const fs = require("fs");
const crypto = require("crypto");
// Generate a random filename for the output file
const randomId = crypto.randomBytes(8).toString("hex");
const outputFile = `/tmp/aw_output_${randomId}.txt`;
// Ensure the /tmp directory exists
fs.mkdirSync("/tmp", { recursive: true });
// We don't create the file, as the name is sufficiently random
// and some engines (Claude) fails first Write to the file
// if it exists and has not been read.
// Set the environment variable for subsequent steps
core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", outputFile);
// Also set as step output for reference
core.setOutput("output_file", outputFile);
}
main();
- name: Setup Safe Outputs
run: |
cat >> $GITHUB_ENV << 'EOF'
GITHUB_AW_SAFE_OUTPUTS_CONFIG={"missing-tool":{"enabled":true}}
EOF
mkdir -p /tmp/safe-outputs
cat > /tmp/safe-outputs/mcp-server.cjs << 'EOF'
const fs = require("fs");
const encoder = new TextEncoder();
const configEnv = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG;
if (!configEnv) throw new Error("GITHUB_AW_SAFE_OUTPUTS_CONFIG not set");
const safeOutputsConfig = JSON.parse(configEnv);
const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS;
if (!outputFile)
throw new Error("GITHUB_AW_SAFE_OUTPUTS not set, no output file");
const SERVER_INFO = { name: "safe-outputs-mcp-server", version: "1.0.0" };
function writeMessage(obj) {
const json = JSON.stringify(obj);
const bytes = encoder.encode(json);
const header = `Content-Length: ${bytes.byteLength}\r\n\r\n`;
const headerBytes = encoder.encode(header);
fs.writeSync(1, headerBytes);
fs.writeSync(1, bytes);
}
let buffer = Buffer.alloc(0);
function onData(chunk) {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
const sep = buffer.indexOf("\r\n\r\n");
if (sep === -1) break;
const headerPart = buffer.slice(0, sep).toString("utf8");
const match = headerPart.match(/Content-Length:\s*(\d+)/i);
if (!match) {
buffer = buffer.slice(sep + 4);
continue;
}
const length = parseInt(match[1], 10);
const total = sep + 4 + length;
if (buffer.length < total) break; // wait for full body
const body = buffer.slice(sep + 4, total);
buffer = buffer.slice(total);
try {
const msg = JSON.parse(body.toString("utf8"));
handleMessage(msg);
} catch (e) {
const err = {
jsonrpc: "2.0",
id: null,
error: { code: -32700, message: "Parse error", data: String(e) },
};
writeMessage(err);
}
}
}
process.stdin.on("data", onData);
process.stdin.on("error", () => {});
process.stdin.resume();
function replyResult(id, result) {
if (id === undefined || id === null) return; // notification
const res = { jsonrpc: "2.0", id, result };
writeMessage(res);
}
function replyError(id, code, message, data) {
const res = {
jsonrpc: "2.0",
id: id ?? null,
error: { code, message, data },
};
writeMessage(res);
}
function isToolEnabled(name) {
return safeOutputsConfig[name] && safeOutputsConfig[name].enabled;
}
function appendSafeOutput(entry) {
if (!outputFile) throw new Error("No output file configured");
const jsonLine = JSON.stringify(entry) + "\n";
try {
fs.appendFileSync(outputFile, jsonLine);
} catch (error) {
throw new Error(
`Failed to write to output file: ${error instanceof Error ? error.message : String(error)}`
);
}
}
const defaultHandler = type => async args => {
const entry = { ...(args || {}), type };
appendSafeOutput(entry);
return {
content: [
{
type: "text",
text: `success`,
},
],
};
};
const TOOLS = Object.fromEntries(
[
{
name: "create-issue",
description: "Create a new GitHub issue",
inputSchema: {
type: "object",
required: ["title", "body"],
properties: {
title: { type: "string", description: "Issue title" },
body: { type: "string", description: "Issue body/description" },
labels: {
type: "array",
items: { type: "string" },
description: "Issue labels",
},
},
additionalProperties: false,
},
},
{
name: "create-discussion",
description: "Create a new GitHub discussion",
inputSchema: {
type: "object",
required: ["title", "body"],
properties: {
title: { type: "string", description: "Discussion title" },
body: { type: "string", description: "Discussion body/content" },
category: { type: "string", description: "Discussion category" },
},
additionalProperties: false,
},
},
{
name: "add-issue-comment",
description: "Add a comment to a GitHub issue or pull request",
inputSchema: {
type: "object",
required: ["body"],
properties: {
body: { type: "string", description: "Comment body/content" },
issue_number: {
type: "number",
description: "Issue or PR number (optional for current context)",
},
},
additionalProperties: false,
},
},
{
name: "create-pull-request",
description: "Create a new GitHub pull request",
inputSchema: {
type: "object",
required: ["title", "body"],
properties: {
title: { type: "string", description: "Pull request title" },
body: {
type: "string",
description: "Pull request body/description",
},
branch: {
type: "string",
description:
"Optional branch name (will be auto-generated if not provided)",
},
labels: {
type: "array",
items: { type: "string" },
description: "Optional labels to add to the PR",
},
},
additionalProperties: false,
},
},
{
name: "create-pull-request-review-comment",
description: "Create a review comment on a GitHub pull request",
inputSchema: {
type: "object",
required: ["path", "line", "body"],
properties: {
path: {
type: "string",
description: "File path for the review comment",
},
line: {
type: ["number", "string"],
description: "Line number for the comment",
},
body: { type: "string", description: "Comment body content" },
start_line: {
type: ["number", "string"],
description: "Optional start line for multi-line comments",
},
side: {
type: "string",
enum: ["LEFT", "RIGHT"],
description: "Optional side of the diff: LEFT or RIGHT",
},
},
additionalProperties: false,
},
},
{
name: "create-code-scanning-alert",
description: "Create a code scanning alert",
inputSchema: {
type: "object",
required: ["file", "line", "severity", "message"],
properties: {
file: {
type: "string",
description: "File path where the issue was found",
},
line: {
type: ["number", "string"],
description: "Line number where the issue was found",
},
severity: {
type: "string",
enum: ["error", "warning", "info", "note"],
description: "Severity level",
},
message: {
type: "string",
description: "Alert message describing the issue",
},
column: {
type: ["number", "string"],
description: "Optional column number",
},
ruleIdSuffix: {
type: "string",
description: "Optional rule ID suffix for uniqueness",
},
},
additionalProperties: false,
},
},
{
name: "add-issue-label",
description: "Add labels to a GitHub issue or pull request",
inputSchema: {
type: "object",
required: ["labels"],
properties: {
labels: {
type: "array",
items: { type: "string" },
description: "Labels to add",
},
issue_number: {
type: "number",
description: "Issue or PR number (optional for current context)",
},
},
additionalProperties: false,
},
},
{
name: "update-issue",
description: "Update a GitHub issue",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["open", "closed"],
description: "Optional new issue status",
},
title: { type: "string", description: "Optional new issue title" },
body: { type: "string", description: "Optional new issue body" },
issue_number: {
type: ["number", "string"],
description: "Optional issue number for target '*'",
},
},
additionalProperties: false,
},
},
{
name: "push-to-pr-branch",
description: "Push changes to a pull request branch",
inputSchema: {
type: "object",
properties: {
message: { type: "string", description: "Optional commit message" },
pull_request_number: {
type: ["number", "string"],
description: "Optional pull request number for target '*'",
},
},
additionalProperties: false,
},
},
{
name: "missing-tool",
description:
"Report a missing tool or functionality needed to complete tasks",
inputSchema: {
type: "object",
required: ["tool", "reason"],
properties: {
tool: { type: "string", description: "Name of the missing tool" },
reason: { type: "string", description: "Why this tool is needed" },
alternatives: {
type: "string",
description: "Possible alternatives or workarounds",
},
},
additionalProperties: false,
},
},
]
.filter(({ name }) => isToolEnabled(name))
.map(tool => [tool.name, tool])
);
process.stderr.write(
`[${SERVER_INFO.name}] v${SERVER_INFO.version} ready on stdio\n`
);
process.stderr.write(`[${SERVER_INFO.name}] output file: ${outputFile}\n`);
process.stderr.write(
`[${SERVER_INFO.name}] config: ${JSON.stringify(safeOutputsConfig)}\n`
);
process.stderr.write(
`[${SERVER_INFO.name}] tools: ${Object.keys(TOOLS).join(", ")}\n`
);
if (!Object.keys(TOOLS).length)
throw new Error("No tools enabled in configuration");
function handleMessage(req) {
const { id, method, params } = req;
try {
if (method === "initialize") {
const clientInfo = params?.clientInfo ?? {};
console.error(`client initialized:`, clientInfo);
const protocolVersion = params?.protocolVersion ?? undefined;
const result = {
serverInfo: SERVER_INFO,
...(protocolVersion ? { protocolVersion } : {}),
capabilities: {
tools: {},
},
};
replyResult(id, result);
} else if (method === "tools/list") {
const list = [];
Object.values(TOOLS).forEach(tool => {
list.push({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
});
});
replyResult(id, { tools: list });
} else if (method === "tools/call") {
const name = params?.name;
const args = params?.arguments ?? {};
if (!name || typeof name !== "string") {
replyError(id, -32602, "Invalid params: 'name' must be a string");
return;
}
const tool = TOOLS[name];
if (!tool) {
replyError(id, -32601, `Tool not found: ${name}`);
return;
}
const handler = tool.handler || defaultHandler(tool.name);
// Basic input validation: ensure required fields are present when schema defines them
const requiredFields =
tool.inputSchema && Array.isArray(tool.inputSchema.required)
? tool.inputSchema.required
: [];
if (requiredFields.length) {
const missing = requiredFields.filter(f => args[f] === undefined);
if (missing.length) {
replyError(
id,
-32602,
`Invalid arguments: missing ${missing.map(m => `'${m}'`).join(", ")}`
);
return;
}
}
(async () => {
try {
const result = await handler(args);
// Handler is expected to return an object possibly containing 'content'.
// If handler returns a primitive or undefined, send an empty content array
const content = result && result.content ? result.content : [];
replyResult(id, { content });
} catch (e) {
replyError(id, -32000, `Tool '${name}' failed`, {
message: e instanceof Error ? e.message : String(e),
});
}
})();
return;
}
replyError(id, -32601, `Method not found: ${method}`);
} catch (e) {
replyError(id, -32603, "Internal error", {
message: e instanceof Error ? e.message : String(e),
});
}
}
process.stderr.write(`[${SERVER_INFO.name}] listening...\n`);
EOF
chmod +x /tmp/safe-outputs/mcp-server.cjs
- name: Setup MCPs
run: |
mkdir -p /tmp/mcp-config
cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
{
"mcpServers": {
"safe_outputs": {
"command": "node",
"args": ["/tmp/safe-outputs/mcp-server.cjs"],
"env": {
"GITHUB_AW_SAFE_OUTPUTS": "${GITHUB_AW_SAFE_OUTPUTS}",
"GITHUB_AW_SAFE_OUTPUTS_CONFIG": "${GITHUB_AW_SAFE_OUTPUTS_CONFIG}"
}
},
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-09deac4"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
}
}
}
}
EOF
- name: Create prompt
env:
GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt
GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}
run: |
mkdir -p /tmp/aw-prompts
cat > $GITHUB_AW_PROMPT << 'EOF'
Write a short poem.
<!-- This workflow tests the integration with the Claude AI engine.
Meant as a scratchpad in pull requests. -->
---
## Reporting Missing Tools or Functionality
**IMPORTANT**: To do the actions mentioned in the header of this section, use the **safe-outputs** tools, do NOT attempt to use `gh`, do NOT attempt to use the GitHub API. You don't have write access to the GitHub repo.
EOF
- name: Print prompt to step summary
run: |
echo "## Generated Prompt" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '``````markdown' >> $GITHUB_STEP_SUMMARY
cat $GITHUB_AW_PROMPT >> $GITHUB_STEP_SUMMARY
echo '``````' >> $GITHUB_STEP_SUMMARY
env:
GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt
- name: Generate agentic run info
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const awInfo = {
engine_id: "claude",
engine_name: "Claude Code",
model: "",
version: "",
workflow_name: "Dev",
experimental: false,
supports_tools_whitelist: true,
supports_http_transport: true,
run_id: context.runId,
run_number: context.runNumber,
run_attempt: process.env.GITHUB_RUN_ATTEMPT,
repository: context.repo.owner + '/' + context.repo.repo,
ref: context.ref,
sha: context.sha,
actor: context.actor,
event_name: context.eventName,
staged: true,
created_at: new Date().toISOString()
};
// Write to /tmp directory to avoid inclusion in PR
const tmpPath = '/tmp/aw_info.json';
fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2));
console.log('Generated aw_info.json at:', tmpPath);
console.log(JSON.stringify(awInfo, null, 2));
- name: Upload agentic run info
if: always()
uses: actions/upload-artifact@v4
with:
name: aw_info.json
path: /tmp/aw_info.json
if-no-files-found: warn
- name: Execute Claude Code Action
id: agentic_execution
uses: anthropics/[email protected]
with:
# Allowed tools (sorted):
# - ExitPlanMode
# - Glob
# - Grep
# - LS
# - NotebookRead
# - Read
# - Task
# - TodoWrite
# - Write
# - mcp__github__download_workflow_run_artifact
# - mcp__github__get_code_scanning_alert
# - mcp__github__get_commit
# - mcp__github__get_dependabot_alert
# - mcp__github__get_discussion
# - mcp__github__get_discussion_comments
# - mcp__github__get_file_contents
# - mcp__github__get_issue
# - mcp__github__get_issue_comments
# - mcp__github__get_job_logs
# - mcp__github__get_me
# - mcp__github__get_notification_details
# - mcp__github__get_pull_request
# - mcp__github__get_pull_request_comments
# - mcp__github__get_pull_request_diff
# - mcp__github__get_pull_request_files
# - mcp__github__get_pull_request_reviews
# - mcp__github__get_pull_request_status
# - mcp__github__get_secret_scanning_alert
# - mcp__github__get_tag
# - mcp__github__get_workflow_run
# - mcp__github__get_workflow_run_logs
# - mcp__github__get_workflow_run_usage
# - mcp__github__list_branches
# - mcp__github__list_code_scanning_alerts
# - mcp__github__list_commits
# - mcp__github__list_dependabot_alerts
# - mcp__github__list_discussion_categories
# - mcp__github__list_discussions
# - mcp__github__list_issues
# - mcp__github__list_notifications
# - mcp__github__list_pull_requests
# - mcp__github__list_secret_scanning_alerts
# - mcp__github__list_tags
# - mcp__github__list_workflow_jobs
# - mcp__github__list_workflow_run_artifacts
# - mcp__github__list_workflow_runs
# - mcp__github__list_workflows
# - mcp__github__search_code
# - mcp__github__search_issues
# - mcp__github__search_orgs
# - mcp__github__search_pull_requests
# - mcp__github__search_repositories
# - mcp__github__search_users
allowed_tools: "ExitPlanMode,Glob,Grep,LS,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_env: |
GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}
GITHUB_AW_SAFE_OUTPUTS_CONFIG: ${{ env.GITHUB_AW_SAFE_OUTPUTS_CONFIG }}
GITHUB_AW_SAFE_OUTPUTS_STAGED: "true"
max_turns: 5
mcp_config: /tmp/mcp-config/mcp-servers.json
mcp_debug: true
prompt_file: /tmp/aw-prompts/prompt.txt
settings: /tmp/.claude/settings.json
timeout_minutes: 5
env:
GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt
GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}
GITHUB_AW_MAX_TURNS: 5
- name: Capture Agentic Action logs
if: always()
run: |
# Copy the detailed execution file from Agentic Action if available
if [ -n "${{ steps.agentic_execution.outputs.execution_file }}" ] && [ -f "${{ steps.agentic_execution.outputs.execution_file }}" ]; then
cp ${{ steps.agentic_execution.outputs.execution_file }} /tmp/dev.log
else
echo "No execution file output found from Agentic Action" >> /tmp/dev.log
fi
# Ensure log file exists
touch /tmp/dev.log
- name: Print Agent output
env:
GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}
run: |
echo "## Agent Output (JSONL)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '``````json' >> $GITHUB_STEP_SUMMARY
if [ -f ${{ env.GITHUB_AW_SAFE_OUTPUTS }} ]; then
cat ${{ env.GITHUB_AW_SAFE_OUTPUTS }} >> $GITHUB_STEP_SUMMARY
# Ensure there's a newline after the file content if it doesn't end with one
if [ -s ${{ env.GITHUB_AW_SAFE_OUTPUTS }} ] && [ "$(tail -c1 ${{ env.GITHUB_AW_SAFE_OUTPUTS }})" != "" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
fi
else
echo "No agent output file found" >> $GITHUB_STEP_SUMMARY
fi
echo '``````' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- name: Upload agentic output file
if: always()
uses: actions/upload-artifact@v4
with:
name: safe_output.jsonl
path: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}
if-no-files-found: warn
- name: Ingest agent output
id: collect_output
uses: actions/github-script@v7
env:
GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}
GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"missing-tool\":{\"enabled\":true}}"
with:
script: |
async function main() {
const fs = require("fs");
/**
* Sanitizes content for safe output in GitHub Actions
* @param {string} content - The content to sanitize
* @returns {string} The sanitized content
*/
function sanitizeContent(content) {
if (!content || typeof content !== "string") {
return "";
}
// Read allowed domains from environment variable
const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS;
const defaultAllowedDomains = [
"github.com",
"github.io",
"githubusercontent.com",
"githubassets.com",
"github.dev",
"codespaces.new",
];
const allowedDomains = allowedDomainsEnv
? allowedDomainsEnv
.split(",")
.map(d => d.trim())
.filter(d => d)
: defaultAllowedDomains;
let sanitized = content;
// Neutralize @mentions to prevent unintended notifications
sanitized = neutralizeMentions(sanitized);
// Remove control characters (except newlines and tabs)
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
// XML character escaping
sanitized = sanitized
.replace(/&/g, "&amp;") // Must be first to avoid double-escaping
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
// URI filtering - replace non-https protocols with "(redacted)"
sanitized = sanitizeUrlProtocols(sanitized);
// Domain filtering for HTTPS URIs
sanitized = sanitizeUrlDomains(sanitized);
// Limit total length to prevent DoS (0.5MB max)
const maxLength = 524288;
if (sanitized.length > maxLength) {
sanitized =
sanitized.substring(0, maxLength) +
"\n[Content truncated due to length]";
}
// Limit number of lines to prevent log flooding (65k max)
const lines = sanitized.split("\n");
const maxLines = 65000;
if (lines.length > maxLines) {
sanitized =
lines.slice(0, maxLines).join("\n") +
"\n[Content truncated due to line count]";
}
// Remove ANSI escape sequences
sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
// Neutralize common bot trigger phrases
sanitized = neutralizeBotTriggers(sanitized);
// Trim excessive whitespace
return sanitized.trim();
/**
* Remove unknown domains
* @param {string} s - The string to process
* @returns {string} The string with unknown domains redacted
*/
function sanitizeUrlDomains(s) {
return s.replace(
/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi,
(match, domain) => {
// Extract the hostname part (before first slash, colon, or other delimiter)
const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase();
// Check if this domain or any parent domain is in the allowlist
const isAllowed = allowedDomains.some(allowedDomain => {
const normalizedAllowed = allowedDomain.toLowerCase();
return (
hostname === normalizedAllowed ||
hostname.endsWith("." + normalizedAllowed)
);
});
return isAllowed ? match : "(redacted)";
}
);
}
/**
* Remove unknown protocols except https
* @param {string} s - The string to process
* @returns {string} The string with non-https protocols redacted
*/
function sanitizeUrlProtocols(s) {
// Match both protocol:// and protocol: patterns
return s.replace(
/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi,
(match, protocol) => {
// Allow https (case insensitive), redact everything else
return protocol.toLowerCase() === "https" ? match : "(redacted)";
}
);
}
/**
* Neutralizes @mentions by wrapping them in backticks
* @param {string} s - The string to process
* @returns {string} The string with neutralized mentions
*/
function neutralizeMentions(s) {
// Replace @name or @org/team outside code with `@name`
return s.replace(
/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g,
(_m, p1, p2) => `${p1}\`@${p2}\``
);
}
/**
* Neutralizes bot trigger phrases by wrapping them in backticks
* @param {string} s - The string to process
* @returns {string} The string with neutralized bot triggers
*/
function neutralizeBotTriggers(s) {
// Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc.
return s.replace(
/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi,
(match, action, ref) => `\`${action} #${ref}\``
);
}
}
/**
* Gets the maximum allowed count for a given output type
* @param {string} itemType - The output item type
* @param {any} config - The safe-outputs configuration
* @returns {number} The maximum allowed count
*/
function getMaxAllowedForType(itemType, config) {
// Check if max is explicitly specified in config
if (
config &&
config[itemType] &&
typeof config[itemType] === "object" &&
config[itemType].max
) {
return config[itemType].max;
}
// Use default limits for plural-supported types
switch (itemType) {
case "create-issue":
return 1; // Only one issue allowed
case "add-issue-comment":
return 1; // Only one comment allowed
case "create-pull-request":
return 1; // Only one pull request allowed
case "create-pull-request-review-comment":
return 10; // Default to 10 review comments allowed
case "add-issue-label":
return 5; // Only one labels operation allowed
case "update-issue":
return 1; // Only one issue update allowed
case "push-to-pr-branch":
return 1; // Only one push to branch allowed
case "create-discussion":
return 1; // Only one discussion allowed
case "missing-tool":
return 1000; // Allow many missing tool reports (default: unlimited)
case "create-code-scanning-alert":
return 1000; // Allow many repository security advisories (default: unlimited)
default:
return 1; // Default to single item for unknown types
}
}
/**
* Attempts to repair common JSON syntax issues in LLM-generated content
* @param {string} jsonStr - The potentially malformed JSON string
* @returns {string} The repaired JSON string
*/
function repairJson(jsonStr) {
let repaired = jsonStr.trim();
// remove invalid control characters like
// U+0014 (DC4) — represented here as "\u0014"
// Escape control characters not allowed in JSON strings (U+0000 through U+001F)
// Preserve common JSON escapes for \b, \f, \n, \r, \t and use \uXXXX for the rest.
/** @type {Record<number, string>} */
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");
});
// Fix single quotes to double quotes (must be done first)
repaired = repaired.replace(/'/g, '"');
// Fix missing quotes around object keys
repaired = repaired.replace(
/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g,
'$1"$2":'
);
// Fix newlines and tabs inside strings by escaping them
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;
});
// Fix unescaped quotes inside string values
repaired = repaired.replace(
/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g,
(match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`
);
// Fix wrong bracket/brace types - arrays should end with ] not }
repaired = repaired.replace(
/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g,
"$1]"
);
// Fix missing closing braces/brackets
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;
}
// Fix missing closing brackets for arrays
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;
}
// Fix trailing commas in objects and arrays (AFTER fixing brackets/braces)
repaired = repaired.replace(/,(\s*[}\]])/g, "$1");
return repaired;
}
/**
* Attempts to parse JSON with repair fallback
* @param {string} jsonStr - The JSON string to parse
* @returns {Object|undefined} The parsed JSON object, or undefined if parsing fails
*/
function parseJsonWithRepair(jsonStr) {
try {
// First, try normal JSON.parse
return JSON.parse(jsonStr);
} catch (originalError) {
try {
// If that fails, try repairing and parsing again
const repairedJson = repairJson(jsonStr);
return JSON.parse(repairedJson);
} catch (repairError) {
// If repair also fails, throw the error
core.info(`invalid input json: ${jsonStr}`);
const originalMsg =
originalError instanceof Error
? originalError.message
: String(originalError);
const repairMsg =
repairError instanceof Error
? repairError.message
: String(repairError);
throw new Error(
`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`
);
}
}
}
const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS;
const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG;
if (!outputFile) {
core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect");
core.setOutput("output", "");
return;
}
if (!fs.existsSync(outputFile)) {
core.info(`Output file does not exist: ${outputFile}`);
core.setOutput("output", "");
return;
}
const outputContent = fs.readFileSync(outputFile, "utf8");
if (outputContent.trim() === "") {
core.info("Output file is empty");
core.setOutput("output", "");
return;
}
core.info(`Raw output content length: ${outputContent.length}`);
// Parse the safe-outputs configuration
/** @type {any} */
let expectedOutputTypes = {};
if (safeOutputsConfig) {
try {
expectedOutputTypes = JSON.parse(safeOutputsConfig);
core.info(
`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`
);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`);
}
}
// Parse JSONL content
const lines = outputContent.trim().split("\n");
const parsedItems = [];
const errors = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === "") continue; // Skip empty lines
try {
/** @type {any} */
const item = parseJsonWithRepair(line);
// If item is undefined (failed to parse), add error and process next line
if (item === undefined) {
errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`);
continue;
}
// Validate that the item has a 'type' field
if (!item.type) {
errors.push(`Line ${i + 1}: Missing required 'type' field`);
continue;
}
// Validate against expected output types
const itemType = item.type;
if (!expectedOutputTypes[itemType]) {
errors.push(
`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`
);
continue;
}
// Check for too many items of the same type
const typeCount = parsedItems.filter(
existing => existing.type === itemType
).length;
const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes);
if (typeCount >= maxAllowed) {
errors.push(
`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`
);
continue;
}
// Basic validation based on type
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;
}
// Sanitize text content
item.title = sanitizeContent(item.title);
item.body = sanitizeContent(item.body);
// Sanitize labels if present
if (item.labels && Array.isArray(item.labels)) {
item.labels = item.labels.map(
/** @param {any} label */ label =>
typeof label === "string" ? sanitizeContent(label) : label
);
}
break;
case "add-issue-comment":
if (!item.body || typeof item.body !== "string") {
errors.push(
`Line ${i + 1}: add-issue-comment requires a 'body' string field`
);
continue;
}
// Sanitize text content
item.body = sanitizeContent(item.body);
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;
}
// Sanitize text content
item.title = sanitizeContent(item.title);
item.body = sanitizeContent(item.body);
// Sanitize branch name if present
if (item.branch && typeof item.branch === "string") {
item.branch = sanitizeContent(item.branch);
}
// Sanitize labels if present
if (item.labels && Array.isArray(item.labels)) {
item.labels = item.labels.map(
/** @param {any} label */ label =>
typeof label === "string" ? sanitizeContent(label) : label
);
}
break;
case "add-issue-label":
if (!item.labels || !Array.isArray(item.labels)) {
errors.push(
`Line ${i + 1}: add-issue-label requires a 'labels' array field`
);
continue;
}
if (
item.labels.some(
/** @param {any} label */ label => typeof label !== "string"
)
) {
errors.push(
`Line ${i + 1}: add-issue-label labels array must contain only strings`
);
continue;
}
// Sanitize label strings
item.labels = item.labels.map(
/** @param {any} label */ label => sanitizeContent(label)
);
break;
case "update-issue":
// Check that at least one updateable field is provided
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;
}
// Validate status if provided
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;
}
}
// Validate title if provided
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);
}
// Validate body if provided
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);
}
// Validate issue_number if provided (for target "*")
if (item.issue_number !== undefined) {
if (
typeof item.issue_number !== "number" &&
typeof item.issue_number !== "string"
) {
errors.push(
`Line ${i + 1}: update-issue 'issue_number' must be a number or string`
);
continue;
}
}
break;
case "push-to-pr-branch":
// Validate message if provided (optional)
if (item.message !== undefined) {
if (typeof item.message !== "string") {
errors.push(
`Line ${i + 1}: push-to-pr-branch 'message' must be a string`
);
continue;
}
item.message = sanitizeContent(item.message);
}
// Validate pull_request_number if provided (for target "*")
if (item.pull_request_number !== undefined) {
if (
typeof item.pull_request_number !== "number" &&
typeof item.pull_request_number !== "string"
) {
errors.push(
`Line ${i + 1}: push-to-pr-branch 'pull_request_number' must be a number or string`
);
continue;
}
}
break;
case "create-pull-request-review-comment":
// Validate required path field
if (!item.path || typeof item.path !== "string") {
errors.push(
`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`
);
continue;
}
// Validate required line field
if (
item.line === undefined ||
(typeof item.line !== "number" && typeof item.line !== "string")
) {
errors.push(
`Line ${i + 1}: create-pull-request-review-comment requires a 'line' number or string field`
);
continue;
}
// Validate line is a positive integer
const lineNumber =
typeof item.line === "string" ? parseInt(item.line, 10) : item.line;
if (
isNaN(lineNumber) ||
lineNumber <= 0 ||
!Number.isInteger(lineNumber)
) {
errors.push(
`Line ${i + 1}: create-pull-request-review-comment 'line' must be a positive integer`
);
continue;
}
// Validate required body field
if (!item.body || typeof item.body !== "string") {
errors.push(
`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`
);
continue;
}
// Sanitize required text content
item.body = sanitizeContent(item.body);
// Validate optional start_line field
if (item.start_line !== undefined) {
if (
typeof item.start_line !== "number" &&
typeof item.start_line !== "string"
) {
errors.push(
`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be a number or string`
);
continue;
}
const startLineNumber =
typeof item.start_line === "string"
? parseInt(item.start_line, 10)
: item.start_line;
if (
isNaN(startLineNumber) ||
startLineNumber <= 0 ||
!Number.isInteger(startLineNumber)
) {
errors.push(
`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be a positive integer`
);
continue;
}
if (startLineNumber > lineNumber) {
errors.push(
`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`
);
continue;
}
}
// Validate optional side field
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;
}
// Sanitize text content
item.title = sanitizeContent(item.title);
item.body = sanitizeContent(item.body);
break;
case "missing-tool":
// Validate required tool field
if (!item.tool || typeof item.tool !== "string") {
errors.push(
`Line ${i + 1}: missing-tool requires a 'tool' string field`
);
continue;
}
// Validate required reason field
if (!item.reason || typeof item.reason !== "string") {
errors.push(
`Line ${i + 1}: missing-tool requires a 'reason' string field`
);
continue;
}
// Sanitize text content
item.tool = sanitizeContent(item.tool);
item.reason = sanitizeContent(item.reason);
// Validate optional alternatives field
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);
}
break;
case "create-code-scanning-alert":
// Validate required fields
if (!item.file || typeof item.file !== "string") {
errors.push(
`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`
);
continue;
}
if (
item.line === undefined ||
item.line === null ||
(typeof item.line !== "number" && typeof item.line !== "string")
) {
errors.push(
`Line ${i + 1}: create-code-scanning-alert requires a 'line' field (number or string)`
);
continue;
}
// Additional validation: line must be parseable as a positive integer
const parsedLine = parseInt(item.line, 10);
if (isNaN(parsedLine) || parsedLine <= 0) {
errors.push(
`Line ${i + 1}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${item.line})`
);
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;
}
// Validate severity level
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(", ")}`
);
continue;
}
// Validate optional column field
if (item.column !== undefined) {
if (
typeof item.column !== "number" &&
typeof item.column !== "string"
) {
errors.push(
`Line ${i + 1}: create-code-scanning-alert 'column' must be a number or string`
);
continue;
}
// Additional validation: must be parseable as a positive integer
const parsedColumn = parseInt(item.column, 10);
if (isNaN(parsedColumn) || parsedColumn <= 0) {
errors.push(
`Line ${i + 1}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${item.column})`
);
continue;
}
}
// Validate optional ruleIdSuffix field
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;
}
}
// Normalize severity to lowercase and sanitize string fields
item.severity = item.severity.toLowerCase();
item.file = sanitizeContent(item.file);
item.severity = sanitizeContent(item.severity);
item.message = sanitizeContent(item.message);
if (item.ruleIdSuffix) {
item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix);
}
break;
default:
errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`);
continue;
}
core.info(`Line ${i + 1}: Valid ${itemType} item`);
parsedItems.push(item);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`);
}
}
// Report validation results
if (errors.length > 0) {
core.warning("Validation errors found:");
errors.forEach(error => core.warning(` - ${error}`));
if (parsedItems.length === 0) {
core.setFailed(errors.map(e => ` - ${e}`).join("\n"));
return;
}
// For now, we'll continue with valid items but log the errors
// In the future, we might want to fail the workflow for invalid items
}
core.info(`Successfully parsed ${parsedItems.length} valid output items`);
// Set the parsed and validated items as output
const validatedOutput = {
items: parsedItems,
errors: errors,
};
// Store validatedOutput JSON in "agent_output.json" file
const agentOutputFile = "/tmp/agent_output.json";
const validatedOutputJson = JSON.stringify(validatedOutput);
try {
// Ensure the /tmp directory exists
fs.mkdirSync("/tmp", { recursive: true });
fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8");
core.info(`Stored validated output to: ${agentOutputFile}`);
// Set the environment variable GITHUB_AW_AGENT_OUTPUT to the file path
core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
core.error(`Failed to write agent output file: ${errorMsg}`);
}
core.setOutput("output", JSON.stringify(validatedOutput));
core.setOutput("raw_output", outputContent);
}
// Call the main function
await main();
- name: Print sanitized agent output
run: |
echo "## Processed Output" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '``````json' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.collect_output.outputs.output }}' >> $GITHUB_STEP_SUMMARY
echo '``````' >> $GITHUB_STEP_SUMMARY
- name: Upload sanitized agent output
if: always() && env.GITHUB_AW_AGENT_OUTPUT
uses: actions/upload-artifact@v4
with:
name: agent_output.json
path: ${{ env.GITHUB_AW_AGENT_OUTPUT }}
if-no-files-found: warn
- name: Upload engine output files
uses: actions/upload-artifact@v4
with:
name: agent_outputs
path: |
output.txt
if-no-files-found: ignore
- name: Clean up engine output files
run: |
rm -f output.txt
- name: Parse agent logs for step summary
if: always()
uses: actions/github-script@v7
env:
GITHUB_AW_AGENT_OUTPUT: /tmp/dev.log
with:
script: |
function main() {
const fs = require("fs");
try {
// Get the log file path from environment
const logFile = process.env.GITHUB_AW_AGENT_OUTPUT;
if (!logFile) {
core.info("No agent log file specified");
return;
}
if (!fs.existsSync(logFile)) {
core.info(`Log file not found: ${logFile}`);
return;
}
const logContent = fs.readFileSync(logFile, "utf8");
const result = parseClaudeLog(logContent);
// Append to GitHub step summary
core.summary.addRaw(result.markdown).write();
// Check for MCP server failures and fail the job if any occurred
if (result.mcpFailures && result.mcpFailures.length > 0) {
const failedServers = result.mcpFailures.join(", ");
core.setFailed(`MCP server(s) failed to launch: ${failedServers}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
core.setFailed(errorMessage);
}
}
/**
* Parses Claude log content and converts it to markdown format
* @param {string} logContent - The raw log content as a string
* @returns {{markdown: string, mcpFailures: string[]}} Result with formatted markdown content and MCP failure list
*/
function parseClaudeLog(logContent) {
try {
const logEntries = JSON.parse(logContent);
if (!Array.isArray(logEntries)) {
return {
markdown:
"## Agent Log Summary\n\nLog format not recognized as Claude JSON array.\n",
mcpFailures: [],
};
}
let markdown = "";
const mcpFailures = [];
// Check for initialization data first
const initEntry = logEntries.find(
entry => entry.type === "system" && entry.subtype === "init"
);
if (initEntry) {
markdown += "## 🚀 Initialization\n\n";
const initResult = formatInitializationSummary(initEntry);
markdown += initResult.markdown;
mcpFailures.push(...initResult.mcpFailures);
markdown += "\n";
}
markdown += "## 🤖 Commands and Tools\n\n";
const toolUsePairs = new Map(); // Map tool_use_id to tool_result
const commandSummary = []; // For the succinct summary
// First pass: collect tool results by tool_use_id
for (const entry of logEntries) {
if (entry.type === "user" && entry.message?.content) {
for (const content of entry.message.content) {
if (content.type === "tool_result" && content.tool_use_id) {
toolUsePairs.set(content.tool_use_id, content);
}
}
}
}
// Collect all tool uses for summary
for (const entry of logEntries) {
if (entry.type === "assistant" && entry.message?.content) {
for (const content of entry.message.content) {
if (content.type === "tool_use") {
const toolName = content.name;
const input = content.input || {};
// Skip internal tools - only show external commands and API calls
if (
[
"Read",
"Write",
"Edit",
"MultiEdit",
"LS",
"Grep",
"Glob",
"TodoWrite",
].includes(toolName)
) {
continue; // Skip internal file operations and searches
}
// Find the corresponding tool result to get status
const toolResult = toolUsePairs.get(content.id);
let statusIcon = "❓";
if (toolResult) {
statusIcon = toolResult.is_error === true ? "❌" : "✅";
}
// Add to command summary (only external tools)
if (toolName === "Bash") {
const formattedCommand = formatBashCommand(input.command || "");
commandSummary.push(`* ${statusIcon} \`${formattedCommand}\``);
} else if (toolName.startsWith("mcp__")) {
const mcpName = formatMcpName(toolName);
commandSummary.push(`* ${statusIcon} \`${mcpName}(...)\``);
} else {
// Handle other external tools (if any)
commandSummary.push(`* ${statusIcon} ${toolName}`);
}
}
}
}
}
// Add command summary
if (commandSummary.length > 0) {
for (const cmd of commandSummary) {
markdown += `${cmd}\n`;
}
} else {
markdown += "No commands or tools used.\n";
}
// Add Information section from the last entry with result metadata
markdown += "\n## 📊 Information\n\n";
// Find the last entry with metadata
const lastEntry = logEntries[logEntries.length - 1];
if (
lastEntry &&
(lastEntry.num_turns ||
lastEntry.duration_ms ||
lastEntry.total_cost_usd ||
lastEntry.usage)
) {
if (lastEntry.num_turns) {
markdown += `**Turns:** ${lastEntry.num_turns}\n\n`;
}
if (lastEntry.duration_ms) {
const durationSec = Math.round(lastEntry.duration_ms / 1000);
const minutes = Math.floor(durationSec / 60);
const seconds = durationSec % 60;
markdown += `**Duration:** ${minutes}m ${seconds}s\n\n`;
}
if (lastEntry.total_cost_usd) {
markdown += `**Total Cost:** $${lastEntry.total_cost_usd.toFixed(4)}\n\n`;
}
if (lastEntry.usage) {
const usage = lastEntry.usage;
if (usage.input_tokens || usage.output_tokens) {
markdown += `**Token Usage:**\n`;
if (usage.input_tokens)
markdown += `- Input: ${usage.input_tokens.toLocaleString()}\n`;
if (usage.cache_creation_input_tokens)
markdown += `- Cache Creation: ${usage.cache_creation_input_tokens.toLocaleString()}\n`;
if (usage.cache_read_input_tokens)
markdown += `- Cache Read: ${usage.cache_read_input_tokens.toLocaleString()}\n`;
if (usage.output_tokens)
markdown += `- Output: ${usage.output_tokens.toLocaleString()}\n`;
markdown += "\n";
}
}
if (
lastEntry.permission_denials &&
lastEntry.permission_denials.length > 0
) {
markdown += `**Permission Denials:** ${lastEntry.permission_denials.length}\n\n`;
}
}
markdown += "\n## 🤖 Reasoning\n\n";
// Second pass: process assistant messages in sequence
for (const entry of logEntries) {
if (entry.type === "assistant" && entry.message?.content) {
for (const content of entry.message.content) {
if (content.type === "text" && content.text) {
// Add reasoning text directly (no header)
const text = content.text.trim();
if (text && text.length > 0) {
markdown += text + "\n\n";
}
} else if (content.type === "tool_use") {
// Process tool use with its result
const toolResult = toolUsePairs.get(content.id);
const toolMarkdown = formatToolUse(content, toolResult);
if (toolMarkdown) {
markdown += toolMarkdown;
}
}
}
}
}
return { markdown, mcpFailures };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
markdown: `## Agent Log Summary\n\nError parsing Claude log: ${errorMessage}\n`,
mcpFailures: [],
};
}
}
/**
* Formats initialization information from system init entry
* @param {any} initEntry - The system init entry containing tools, mcp_servers, etc.
* @returns {{markdown: string, mcpFailures: string[]}} Result with formatted markdown string and MCP failure list
*/
function formatInitializationSummary(initEntry) {
let markdown = "";
const mcpFailures = [];
// Display model and session info
if (initEntry.model) {
markdown += `**Model:** ${initEntry.model}\n\n`;
}
if (initEntry.session_id) {
markdown += `**Session ID:** ${initEntry.session_id}\n\n`;
}
if (initEntry.cwd) {
// Show a cleaner path by removing common prefixes
const cleanCwd = initEntry.cwd.replace(
/^\/home\/runner\/work\/[^\/]+\/[^\/]+/,
"."
);
markdown += `**Working Directory:** ${cleanCwd}\n\n`;
}
// Display MCP servers status
if (initEntry.mcp_servers && Array.isArray(initEntry.mcp_servers)) {
markdown += "**MCP Servers:**\n";
for (const server of initEntry.mcp_servers) {
const statusIcon =
server.status === "connected"
? "✅"
: server.status === "failed"
? "❌"
: "❓";
markdown += `- ${statusIcon} ${server.name} (${server.status})\n`;
// Track failed MCP servers
if (server.status === "failed") {
mcpFailures.push(server.name);
}
}
markdown += "\n";
}
// Display tools by category
if (initEntry.tools && Array.isArray(initEntry.tools)) {
markdown += "**Available Tools:**\n";
// Categorize tools
/** @type {{ [key: string]: string[] }} */
const categories = {
Core: [],
"File Operations": [],
"Git/GitHub": [],
MCP: [],
Other: [],
};
for (const tool of initEntry.tools) {
if (
["Task", "Bash", "BashOutput", "KillBash", "ExitPlanMode"].includes(
tool
)
) {
categories["Core"].push(tool);
} else if (
[
"Read",
"Edit",
"MultiEdit",
"Write",
"LS",
"Grep",
"Glob",
"NotebookEdit",
].includes(tool)
) {
categories["File Operations"].push(tool);
} else if (tool.startsWith("mcp__github__")) {
categories["Git/GitHub"].push(formatMcpName(tool));
} else if (
tool.startsWith("mcp__") ||
["ListMcpResourcesTool", "ReadMcpResourceTool"].includes(tool)
) {
categories["MCP"].push(
tool.startsWith("mcp__") ? formatMcpName(tool) : tool
);
} else {
categories["Other"].push(tool);
}
}
// Display categories with tools
for (const [category, tools] of Object.entries(categories)) {
if (tools.length > 0) {
markdown += `- **${category}:** ${tools.length} tools\n`;
if (tools.length <= 5) {
// Show all tools if 5 or fewer
markdown += ` - ${tools.join(", ")}\n`;
} else {
// Show first few and count
markdown += ` - ${tools.slice(0, 3).join(", ")}, and ${tools.length - 3} more\n`;
}
}
}
markdown += "\n";
}
// Display slash commands if available
if (initEntry.slash_commands && Array.isArray(initEntry.slash_commands)) {
const commandCount = initEntry.slash_commands.length;
markdown += `**Slash Commands:** ${commandCount} available\n`;
if (commandCount <= 10) {
markdown += `- ${initEntry.slash_commands.join(", ")}\n`;
} else {
markdown += `- ${initEntry.slash_commands.slice(0, 5).join(", ")}, and ${commandCount - 5} more\n`;
}
markdown += "\n";
}
return { markdown, mcpFailures };
}
/**
* Formats a tool use entry with its result into markdown
* @param {any} toolUse - The tool use object containing name, input, etc.
* @param {any} toolResult - The corresponding tool result object
* @returns {string} Formatted markdown string
*/
function formatToolUse(toolUse, toolResult) {
const toolName = toolUse.name;
const input = toolUse.input || {};
// Skip TodoWrite except the very last one (we'll handle this separately)
if (toolName === "TodoWrite") {
return ""; // Skip for now, would need global context to find the last one
}
// Helper function to determine status icon
function getStatusIcon() {
if (toolResult) {
return toolResult.is_error === true ? "❌" : "✅";
}
return "❓"; // Unknown by default
}
let markdown = "";
const statusIcon = getStatusIcon();
switch (toolName) {
case "Bash":
const command = input.command || "";
const description = input.description || "";
// Format the command to be single line
const formattedCommand = formatBashCommand(command);
if (description) {
markdown += `${description}:\n\n`;
}
markdown += `${statusIcon} \`${formattedCommand}\`\n\n`;
break;
case "Read":
const filePath = input.file_path || input.path || "";
const relativePath = filePath.replace(
/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//,
""
); // Remove /home/runner/work/repo/repo/ prefix
markdown += `${statusIcon} Read \`${relativePath}\`\n\n`;
break;
case "Write":
case "Edit":
case "MultiEdit":
const writeFilePath = input.file_path || input.path || "";
const writeRelativePath = writeFilePath.replace(
/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//,
""
);
markdown += `${statusIcon} Write \`${writeRelativePath}\`\n\n`;
break;
case "Grep":
case "Glob":
const query = input.query || input.pattern || "";
markdown += `${statusIcon} Search for \`${truncateString(query, 80)}\`\n\n`;
break;
case "LS":
const lsPath = input.path || "";
const lsRelativePath = lsPath.replace(
/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//,
""
);
markdown += `${statusIcon} LS: ${lsRelativePath || lsPath}\n\n`;
break;
default:
// Handle MCP calls and other tools
if (toolName.startsWith("mcp__")) {
const mcpName = formatMcpName(toolName);
const params = formatMcpParameters(input);
markdown += `${statusIcon} ${mcpName}(${params})\n\n`;
} else {
// Generic tool formatting - show the tool name and main parameters
const keys = Object.keys(input);
if (keys.length > 0) {
// Try to find the most important parameter
const mainParam =
keys.find(k =>
["query", "command", "path", "file_path", "content"].includes(k)
) || keys[0];
const value = String(input[mainParam] || "");
if (value) {
markdown += `${statusIcon} ${toolName}: ${truncateString(value, 100)}\n\n`;
} else {
markdown += `${statusIcon} ${toolName}\n\n`;
}
} else {
markdown += `${statusIcon} ${toolName}\n\n`;
}
}
}
return markdown;
}
/**
* Formats MCP tool name from internal format to display format
* @param {string} toolName - The raw tool name (e.g., mcp__github__search_issues)
* @returns {string} Formatted tool name (e.g., github::search_issues)
*/
function formatMcpName(toolName) {
// Convert mcp__github__search_issues to github::search_issues
if (toolName.startsWith("mcp__")) {
const parts = toolName.split("__");
if (parts.length >= 3) {
const provider = parts[1]; // github, etc.
const method = parts.slice(2).join("_"); // search_issues, etc.
return `${provider}::${method}`;
}
}
return toolName;
}
/**
* Formats MCP parameters into a human-readable string
* @param {Record<string, any>} input - The input object containing parameters
* @returns {string} Formatted parameters string
*/
function formatMcpParameters(input) {
const keys = Object.keys(input);
if (keys.length === 0) return "";
const paramStrs = [];
for (const key of keys.slice(0, 4)) {
// Show up to 4 parameters
const value = String(input[key] || "");
paramStrs.push(`${key}: ${truncateString(value, 40)}`);
}
if (keys.length > 4) {
paramStrs.push("...");
}
return paramStrs.join(", ");
}
/**
* Formats a bash command by normalizing whitespace and escaping
* @param {string} command - The raw bash command string
* @returns {string} Formatted and escaped command string
*/
function formatBashCommand(command) {
if (!command) return "";
// Convert multi-line commands to single line by replacing newlines with spaces
// and collapsing multiple spaces
let formatted = command
.replace(/\n/g, " ") // Replace newlines with spaces
.replace(/\r/g, " ") // Replace carriage returns with spaces
.replace(/\t/g, " ") // Replace tabs with spaces
.replace(/\s+/g, " ") // Collapse multiple spaces into one
.trim(); // Remove leading/trailing whitespace
// Escape backticks to prevent markdown issues
formatted = formatted.replace(/`/g, "\\`");
// Truncate if too long (keep reasonable length for summary)
const maxLength = 80;
if (formatted.length > maxLength) {
formatted = formatted.substring(0, maxLength) + "...";
}
return formatted;
}
/**
* Truncates a string to a maximum length with ellipsis
* @param {string} str - The string to truncate
* @param {number} maxLength - Maximum allowed length
* @returns {string} Truncated string with ellipsis if needed
*/
function truncateString(str, maxLength) {
if (!str) return "";
if (str.length <= maxLength) return str;
return str.substring(0, maxLength) + "...";
}
// Export for testing
if (typeof module !== "undefined" && module.exports) {
module.exports = {
parseClaudeLog,
formatToolUse,
formatInitializationSummary,
formatBashCommand,
truncateString,
};
}
main();
- name: Upload agent logs
if: always()
uses: actions/upload-artifact@v4
with:
name: dev.log
path: /tmp/dev.log
if-no-files-found: warn
missing_tool:
needs: dev
if: ${{ always() }}
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 5
outputs:
tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
total_count: ${{ steps.missing_tool.outputs.total_count }}
steps:
- name: Record Missing Tool
id: missing_tool
uses: actions/github-script@v7
env:
GITHUB_AW_AGENT_OUTPUT: ${{ needs.dev.outputs.output }}
with:
script: |
async function main() {
const fs = require("fs");
// Get environment variables
const agentOutput = process.env.GITHUB_AW_AGENT_OUTPUT || "";
const maxReports = process.env.GITHUB_AW_MISSING_TOOL_MAX
? parseInt(process.env.GITHUB_AW_MISSING_TOOL_MAX)
: null;
core.info("Processing missing-tool reports...");
core.info(`Agent output length: ${agentOutput.length}`);
if (maxReports) {
core.info(`Maximum reports allowed: ${maxReports}`);
}
/** @type {any[]} */
const missingTools = [];
// Return early if no agent output
if (!agentOutput.trim()) {
core.info("No agent output to process");
core.setOutput("tools_reported", JSON.stringify(missingTools));
core.setOutput("total_count", missingTools.length.toString());
return;
}
// Parse the validated output JSON
let validatedOutput;
try {
validatedOutput = JSON.parse(agentOutput);
} catch (error) {
core.setFailed(
`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`
);
return;
}
if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
core.info("No valid items found in agent output");
core.setOutput("tools_reported", JSON.stringify(missingTools));
core.setOutput("total_count", missingTools.length.toString());
return;
}
core.info(`Parsed agent output with ${validatedOutput.items.length} entries`);
// Process all parsed entries
for (const entry of validatedOutput.items) {
if (entry.type === "missing-tool") {
// Validate required fields
if (!entry.tool) {
core.warning(
`missing-tool entry missing 'tool' field: ${JSON.stringify(entry)}`
);
continue;
}
if (!entry.reason) {
core.warning(
`missing-tool entry missing 'reason' field: ${JSON.stringify(entry)}`
);
continue;
}
const missingTool = {
tool: entry.tool,
reason: entry.reason,
alternatives: entry.alternatives || null,
timestamp: new Date().toISOString(),
};
missingTools.push(missingTool);
core.info(`Recorded missing tool: ${missingTool.tool}`);
// Check max limit
if (maxReports && missingTools.length >= maxReports) {
core.info(
`Reached maximum number of missing tool reports (${maxReports})`
);
break;
}
}
}
core.info(`Total missing tools reported: ${missingTools.length}`);
// Output results
core.setOutput("tools_reported", JSON.stringify(missingTools));
core.setOutput("total_count", missingTools.length.toString());
// Log details for debugging
if (missingTools.length > 0) {
core.info("Missing tools summary:");
missingTools.forEach((tool, index) => {
core.info(`${index + 1}. Tool: ${tool.tool}`);
core.info(` Reason: ${tool.reason}`);
if (tool.alternatives) {
core.info(` Alternatives: ${tool.alternatives}`);
}
core.info(` Reported at: ${tool.timestamp}`);
core.info("");
});
} else {
core.info("No missing tools reported in this workflow execution.");
}
}
main().catch(error => {
core.error(`Error processing missing-tool reports: ${error}`);
core.setFailed(`Error processing missing-tool reports: ${error}`);
});