diff --git a/.github/workflows/issue-triage-agent.lock.yml b/.github/workflows/issue-triage-agent.lock.yml index 2742f160541..ab6c9261a6b 100644 --- a/.github/workflows/issue-triage-agent.lock.yml +++ b/.github/workflows/issue-triage-agent.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"454ac6041d95e15dfd3f1f9509fb83f1ba46170a1ece67f8bbda3547b925fc94","body_hash":"603b350a5038c2fdbcf6f37ed51be9a42f497278fce0926a26676da1d3133528","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"87595ab4f026a6d803fa8c7688cb8c86f196879f3a49e77811fb78fc24b65f8a","body_hash":"603b350a5038c2fdbcf6f37ed51be9a42f497278fce0926a26676da1d3133528","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -529,9 +529,9 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_c355593fe003befd_EOF' - {"add_comment":{"max":1},"add_labels":{"allowed":["bug","feature","enhancement","documentation","question","help-wanted","good-first-issue"]},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_c355593fe003befd_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_cac70be9f301446a_EOF' + {"add_comment":{"data_enabled":true,"data_schema":{"additionalProperties":false,"properties":{"confidence":{"type":"string"},"issue_number":{"type":"integer"},"label":{"type":"string"},"reasoning":{"type":"string"}},"required":["confidence","issue_number","label","reasoning"],"type":"object"},"max":1},"add_labels":{"allowed":["bug","feature","enhancement","documentation","question","help-wanted","good-first-issue"]},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_cac70be9f301446a_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -541,7 +541,165 @@ jobs: "add_labels": " CONSTRAINTS: Only these labels are allowed: [\"bug\" \"feature\" \"enhancement\" \"documentation\" \"question\" \"help-wanted\" \"good-first-issue\"]." }, "repo_params": {}, - "dynamic_tools": [] + "dynamic_tools": [], + "property_injections": { + "add_comment": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "issue_number": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "reasoning": { + "type": "string" + } + }, + "required": [ + "confidence", + "issue_number", + "label", + "reasoning" + ], + "type": "object" + } + }, + "create_issue": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "issue_number": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "reasoning": { + "type": "string" + } + }, + "required": [ + "confidence", + "issue_number", + "label", + "reasoning" + ], + "type": "object" + } + }, + "create_pull_request": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "issue_number": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "reasoning": { + "type": "string" + } + }, + "required": [ + "confidence", + "issue_number", + "label", + "reasoning" + ], + "type": "object" + } + }, + "create_pull_request_review_comment": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "issue_number": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "reasoning": { + "type": "string" + } + }, + "required": [ + "confidence", + "issue_number", + "label", + "reasoning" + ], + "type": "object" + } + }, + "reply_to_pull_request_review_comment": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "issue_number": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "reasoning": { + "type": "string" + } + }, + "required": [ + "confidence", + "issue_number", + "label", + "reasoning" + ], + "type": "object" + } + }, + "submit_pull_request_review": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "issue_number": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "reasoning": { + "type": "string" + } + }, + "required": [ + "confidence", + "issue_number", + "label", + "reasoning" + ], + "type": "object" + } + } + } } GH_AW_VALIDATION_JSON: | { @@ -565,6 +723,31 @@ jobs: "type": "string", "maxLength": 256 } + }, + "dataEnabled": true, + "dataSchema": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "issue_number": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "reasoning": { + "type": "string" + } + }, + "required": [ + "confidence", + "issue_number", + "label", + "reasoning" + ], + "type": "object" } }, "add_labels": { diff --git a/.github/workflows/issue-triage-agent.md b/.github/workflows/issue-triage-agent.md index e2567e2512f..5d2bcb0b292 100644 --- a/.github/workflows/issue-triage-agent.md +++ b/.github/workflows/issue-triage-agent.md @@ -18,6 +18,11 @@ tools: min-integrity: approved toolsets: [issues, labels] safe-outputs: + data: + issue_number: integer + label: string + confidence: string + reasoning: string add-labels: allowed: [bug, feature, enhancement, documentation, question, help-wanted, good-first-issue] add-comment: {} diff --git a/.github/workflows/pr-triage-agent.lock.yml b/.github/workflows/pr-triage-agent.lock.yml index 6907cb18cd6..7b4dc05669e 100644 --- a/.github/workflows/pr-triage-agent.lock.yml +++ b/.github/workflows/pr-triage-agent.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"4f2c7f3c6f5a5329ef7315b1aea22f0d9e09566d60bc7aca2206aaf4fa842893","body_hash":"c1ea772da6c988ce5571c3ef07a7f324834ad7d58cd43d5c460994428350ffb4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75","copilot-sdk":"1.0.8"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"2c1b23b2d4ce5a0bde733c63211c88ce534fd2acff37b11c3d7caf6ab1cb06c5","body_hash":"c1ea772da6c988ce5571c3ef07a7f324834ad7d58cd43d5c460994428350ffb4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75","copilot-sdk":"1.0.8"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.42","digest":"sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.42@sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -584,9 +584,9 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_146afb6b02d95147_EOF' - {"add_comment":{"max":50},"add_labels":{"max":100},"create_check_run":{"max":1},"create_issue":{"close_older_issues":true,"expires":24,"labels":["automation","pr-triage-report"],"max":1,"title_prefix":"[PR Triage Report] "},"create_pull_request_review_comment":{"max":10,"side":"RIGHT"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"push_repo_memory":{"memories":[{"dir":"/tmp/gh-aw/repo-memory/default","id":"default","max_file_count":100,"max_file_size":102400,"max_patch_size":10240}]},"report_incomplete":{},"submit_pull_request_review":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_146afb6b02d95147_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_f19889e623f765c1_EOF' + {"add_comment":{"data_enabled":true,"data_schema":{"additionalProperties":false,"properties":{"action":{"type":"string"},"category":{"type":"string"},"pr_number":{"type":"integer"},"risk":{"type":"string"}},"required":["action","category","pr_number","risk"],"type":"object"},"max":50},"add_labels":{"max":100},"create_check_run":{"max":1},"create_issue":{"close_older_issues":true,"data_enabled":true,"data_schema":{"additionalProperties":false,"properties":{"action":{"type":"string"},"category":{"type":"string"},"pr_number":{"type":"integer"},"risk":{"type":"string"}},"required":["action","category","pr_number","risk"],"type":"object"},"expires":24,"labels":["automation","pr-triage-report"],"max":1,"title_prefix":"[PR Triage Report] "},"create_pull_request_review_comment":{"data_enabled":true,"data_schema":{"additionalProperties":false,"properties":{"action":{"type":"string"},"category":{"type":"string"},"pr_number":{"type":"integer"},"risk":{"type":"string"}},"required":["action","category","pr_number","risk"],"type":"object"},"max":10,"side":"RIGHT"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"push_repo_memory":{"memories":[{"dir":"/tmp/gh-aw/repo-memory/default","id":"default","max_file_count":100,"max_file_size":102400,"max_patch_size":10240}]},"report_incomplete":{},"submit_pull_request_review":{"data_enabled":true,"data_schema":{"additionalProperties":false,"properties":{"action":{"type":"string"},"category":{"type":"string"},"pr_number":{"type":"integer"},"risk":{"type":"string"}},"required":["action","category","pr_number","risk"],"type":"object"},"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_f19889e623f765c1_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -600,7 +600,165 @@ jobs: "submit_pull_request_review": " CONSTRAINTS: Maximum 1 review(s) can be submitted." }, "repo_params": {}, - "dynamic_tools": [] + "dynamic_tools": [], + "property_injections": { + "add_comment": { + "data": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" + } + }, + "create_issue": { + "data": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" + } + }, + "create_pull_request": { + "data": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" + } + }, + "create_pull_request_review_comment": { + "data": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" + } + }, + "reply_to_pull_request_review_comment": { + "data": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" + } + }, + "submit_pull_request_review": { + "data": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" + } + } + } } GH_AW_VALIDATION_JSON: | { @@ -624,6 +782,31 @@ jobs: "type": "string", "maxLength": 256 } + }, + "dataEnabled": true, + "dataSchema": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" } }, "add_labels": { @@ -677,6 +860,31 @@ jobs: "sanitize": true, "maxLength": 128 } + }, + "dataEnabled": true, + "dataSchema": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" } }, "create_pull_request_review_comment": { @@ -714,7 +922,32 @@ jobs: "optionalPositiveInteger": true } }, - "customValidation": "startLineLessOrEqualLine" + "customValidation": "startLineLessOrEqualLine", + "dataEnabled": true, + "dataSchema": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" + } }, "missing_data": { "defaultMax": 20, @@ -812,6 +1045,31 @@ jobs: "type": "string", "maxLength": 256 } + }, + "dataEnabled": true, + "dataSchema": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pr_number": { + "type": "integer" + }, + "risk": { + "type": "string" + } + }, + "required": [ + "action", + "category", + "pr_number", + "risk" + ], + "type": "object" } } } diff --git a/.github/workflows/pr-triage-agent.md b/.github/workflows/pr-triage-agent.md index 687922081a4..a8d97031c7a 100644 --- a/.github/workflows/pr-triage-agent.md +++ b/.github/workflows/pr-triage-agent.md @@ -34,6 +34,11 @@ tools: file-glob: ["*.json", "*.md"] max-file-size: 102400 # 100KB safe-outputs: + data: + pr_number: integer + category: string + risk: string + action: string add-labels: max: 100 # Omitting 'allowed' to permit dynamic label creation (pr-type:*, pr-risk:*, etc.) diff --git a/.github/workflows/security-review.lock.yml b/.github/workflows/security-review.lock.yml index 66cedc0b33c..be73525f87f 100644 --- a/.github/workflows/security-review.lock.yml +++ b/.github/workflows/security-review.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"062a8d79e78745712c90a2c4b813560528fb6ac66f9fa648c31e86d63137de2c","body_hash":"0126247e27d8b79d860c4e5d742c378dbb513c9771307fdcc7c0eba53ec7ec56","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e4bd9e9ef9a948da8d69e7ad61ca5a25d1849b68d91bd2af4f36b8ccc5b5b98b","body_hash":"0126247e27d8b79d860c4e5d742c378dbb513c9771307fdcc7c0eba53ec7ec56","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"v7.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"53b7df96c91f9c12dcc8a07bcb9ccacbed38856a","version":"v7.3.0"},{"repo":"docker/setup-buildx-action","sha":"bb05f3f5519dd87d3ba754cc423b652a5edd6d2c","version":"v4.2.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -679,9 +679,9 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_6c85fb785cea0a84_EOF' - {"create_check_run":{"max":1},"create_pull_request_review_comment":{"max":10,"side":"RIGHT"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{},"submit_pull_request_review":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_6c85fb785cea0a84_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_08323f214296a804_EOF' + {"create_check_run":{"max":1},"create_pull_request_review_comment":{"data_enabled":true,"data_schema":{"additionalProperties":false,"properties":{"confidence":{"type":"string"},"file":{"type":"string"},"finding_type":{"type":"string"},"severity":{"type":"string"}},"required":["confidence","file","finding_type","severity"],"type":"object"},"max":10,"side":"RIGHT"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{},"submit_pull_request_review":{"data_enabled":true,"data_schema":{"additionalProperties":false,"properties":{"confidence":{"type":"string"},"file":{"type":"string"},"finding_type":{"type":"string"},"severity":{"type":"string"}},"required":["confidence","file","finding_type","severity"],"type":"object"},"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_08323f214296a804_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -692,7 +692,165 @@ jobs: "submit_pull_request_review": " CONSTRAINTS: Maximum 1 review(s) can be submitted." }, "repo_params": {}, - "dynamic_tools": [] + "dynamic_tools": [], + "property_injections": { + "add_comment": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "file": { + "type": "string" + }, + "finding_type": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "confidence", + "file", + "finding_type", + "severity" + ], + "type": "object" + } + }, + "create_issue": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "file": { + "type": "string" + }, + "finding_type": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "confidence", + "file", + "finding_type", + "severity" + ], + "type": "object" + } + }, + "create_pull_request": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "file": { + "type": "string" + }, + "finding_type": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "confidence", + "file", + "finding_type", + "severity" + ], + "type": "object" + } + }, + "create_pull_request_review_comment": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "file": { + "type": "string" + }, + "finding_type": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "confidence", + "file", + "finding_type", + "severity" + ], + "type": "object" + } + }, + "reply_to_pull_request_review_comment": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "file": { + "type": "string" + }, + "finding_type": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "confidence", + "file", + "finding_type", + "severity" + ], + "type": "object" + } + }, + "submit_pull_request_review": { + "data": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "file": { + "type": "string" + }, + "finding_type": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "confidence", + "file", + "finding_type", + "severity" + ], + "type": "object" + } + } + } } GH_AW_VALIDATION_JSON: | { @@ -731,7 +889,32 @@ jobs: "optionalPositiveInteger": true } }, - "customValidation": "startLineLessOrEqualLine" + "customValidation": "startLineLessOrEqualLine", + "dataEnabled": true, + "dataSchema": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "file": { + "type": "string" + }, + "finding_type": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "confidence", + "file", + "finding_type", + "severity" + ], + "type": "object" + } }, "missing_data": { "defaultMax": 20, @@ -829,6 +1012,31 @@ jobs: "type": "string", "maxLength": 256 } + }, + "dataEnabled": true, + "dataSchema": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "file": { + "type": "string" + }, + "finding_type": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "confidence", + "file", + "finding_type", + "severity" + ], + "type": "object" } } } diff --git a/.github/workflows/security-review.md b/.github/workflows/security-review.md index 7336da129df..6e0d2f98dd8 100644 --- a/.github/workflows/security-review.md +++ b/.github/workflows/security-review.md @@ -28,6 +28,11 @@ tools: edit: web-fetch: safe-outputs: + data: + finding_type: string + severity: string + confidence: string + file: string messages: footer: "> 🔒 *Security review by [{workflow_name}]({run_url})*{ai_credits_suffix}{history_link}" run-started: "🔍 [{workflow_name}]({run_url}) is analyzing this {event_type} for security implications..." diff --git a/.github/workflows/smoke-copilot-auto.lock.yml b/.github/workflows/smoke-copilot-auto.lock.yml index 5207103f95d..ec93a6a2df0 100644 --- a/.github/workflows/smoke-copilot-auto.lock.yml +++ b/.github/workflows/smoke-copilot-auto.lock.yml @@ -149,7 +149,7 @@ jobs: GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FRONTMATTER_EMOJI: "🌸" GH_AW_COMPILED_STRICT: "true" - GH_AW_INFO_MODEL_COSTS: '{"providers":{"github-copilot":{"models":{"auto":{"cost":{"input":"8.5e-07","output":"1.55e-06"}}}}}}' + GH_AW_INFO_MODEL_COSTS: '{"providers":{"github-copilot":{"models":{"auto":{"cost":{"input":"0","output":"0"}}}}}}' GH_AW_INFO_FEATURES: '{"gh-aw-detection":false}' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/actions/setup/js/collect_ndjson_output.cjs b/actions/setup/js/collect_ndjson_output.cjs index e04f92dfb36..b3615880f7f 100644 --- a/actions/setup/js/collect_ndjson_output.cjs +++ b/actions/setup/js/collect_ndjson_output.cjs @@ -343,6 +343,8 @@ async function main() { allowedAliases: allowedMentions, maxBotMentions, normalizeIssueClosingKeywords, + dataEnabled: typeConfig !== null && typeof typeConfig === "object" && typeConfig.data_enabled === true, + dataSchema: typeConfig !== null && typeof typeConfig === "object" ? typeConfig.data_schema : undefined, }); if (!validationResult.isValid) { if (validationResult.error) { diff --git a/actions/setup/js/data_schema_normalizer.cjs b/actions/setup/js/data_schema_normalizer.cjs new file mode 100644 index 00000000000..4a5891b6c3d --- /dev/null +++ b/actions/setup/js/data_schema_normalizer.cjs @@ -0,0 +1,170 @@ +// @ts-check + +const SUPPORTED_TYPES = new Set(["object", "array", "string", "number", "integer", "boolean"]); +const ALLOWED_KEYS = new Set(["type", "description", "properties", "required", "items", "enum", "additionalProperties", "minLength", "maxLength", "minimum", "maximum", "pattern"]); + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hasSchemaKeyword(value) { + return Object.keys(value).some(key => ALLOWED_KEYS.has(key)); +} + +/** + * @param {any} raw + * @param {string} path + * @param {boolean} allowShorthand + * @returns {Record} + */ +function simplifySchemaNode(raw, path, allowShorthand) { + if (typeof raw === "string") { + if (!allowShorthand) { + throw new Error(`${path}: string shorthand is not allowed here`); + } + if (!SUPPORTED_TYPES.has(raw)) { + throw new Error(`${path}: unsupported type "${raw}"`); + } + return { type: raw }; + } + + if (!isPlainObject(raw)) { + throw new Error(`${path}: expected an object schema`); + } + + let node = raw; + let explicit = hasSchemaKeyword(node); + if (!explicit && allowShorthand) { + explicit = true; + node = { type: "object", properties: node }; + } + if (!explicit) { + throw new Error(`${path}: expected JSON schema keywords or shorthand properties`); + } + + for (const key of Object.keys(node)) { + if (!ALLOWED_KEYS.has(key)) { + throw new Error(`${path}: unsupported keyword "${key}"`); + } + } + + const result = {}; + let typeName = typeof node.type === "string" ? node.type : ""; + if (!typeName) { + if (node.properties !== undefined || node.required !== undefined || node.additionalProperties !== undefined) { + typeName = "object"; + } else if (node.items !== undefined) { + typeName = "array"; + } + } + if (typeName) { + if (!SUPPORTED_TYPES.has(typeName)) { + throw new Error(`${path}.type: unsupported type "${typeName}"`); + } + result.type = typeName; + } + + if (node.description !== undefined) { + if (typeof node.description !== "string") { + throw new Error(`${path}.description: must be a string`); + } + result.description = node.description; + } + + if (node.enum !== undefined) { + if (!Array.isArray(node.enum) || node.enum.length === 0) { + throw new Error(`${path}.enum: must be a non-empty array`); + } + for (let i = 0; i < node.enum.length; i++) { + const enumItem = node.enum[i]; + if (typeof enumItem !== "string" && typeof enumItem !== "number" && typeof enumItem !== "boolean") { + throw new Error(`${path}.enum[${i}]: must be a scalar value`); + } + } + result.enum = node.enum; + } + + if (typeName === "object") { + if (!isPlainObject(node.properties)) { + throw new Error(`${path}.properties: is required for object schemas`); + } + const normalizedProperties = {}; + for (const [key, value] of Object.entries(node.properties)) { + normalizedProperties[key] = simplifySchemaNode(value, `${path}.properties.${key}`, true); + } + result.properties = normalizedProperties; + + const requiredSet = new Set(Object.keys(normalizedProperties)); + if (node.required !== undefined) { + if (!Array.isArray(node.required)) { + throw new Error(`${path}.required: must be an array of strings`); + } + for (let i = 0; i < node.required.length; i++) { + const requiredName = node.required[i]; + if (typeof requiredName !== "string" || requiredName.trim().length === 0) { + throw new Error(`${path}.required[${i}]: must be a non-empty string`); + } + if (!Object.prototype.hasOwnProperty.call(normalizedProperties, requiredName)) { + throw new Error(`${path}.required[${i}]: unknown property "${requiredName}"`); + } + requiredSet.add(requiredName); + } + } + result.required = [...requiredSet].sort(); + + if (node.additionalProperties !== undefined) { + if (typeof node.additionalProperties !== "boolean") { + throw new Error(`${path}.additionalProperties: must be boolean`); + } + if (node.additionalProperties) { + throw new Error(`${path}.additionalProperties: must be false for OpenAI Codex structured outputs compatibility`); + } + } + result.additionalProperties = false; + } else if (typeName === "array") { + if (node.items === undefined) { + throw new Error(`${path}.items: is required for array schemas`); + } + result.items = simplifySchemaNode(node.items, `${path}.items`, true); + } else if (typeName === "string") { + if (node.minLength !== undefined) result.minLength = node.minLength; + if (node.maxLength !== undefined) result.maxLength = node.maxLength; + if (node.pattern !== undefined) result.pattern = node.pattern; + } else if (typeName === "number" || typeName === "integer") { + if (node.minimum !== undefined) result.minimum = node.minimum; + if (node.maximum !== undefined) result.maximum = node.maximum; + } + + return result; +} + +/** + * @param {any} rawSchema + * @param {string} path + * @returns {Record} + */ +function resolveDataSchema(rawSchema, path) { + if (isPlainObject(rawSchema)) { + const normalized = simplifySchemaNode(rawSchema, path, true); + if (normalized.type !== "object") { + throw new Error(`${path}: must resolve to an object schema`); + } + return normalized; + } + if (typeof rawSchema === "string") { + const parsed = JSON.parse(rawSchema); + if (!isPlainObject(parsed)) { + throw new Error(`${path}: string JSON must decode to an object schema`); + } + const normalized = simplifySchemaNode(parsed, path, true); + if (normalized.type !== "object") { + throw new Error(`${path}: must resolve to an object schema`); + } + return normalized; + } + throw new Error(`${path}: must be an object schema or JSON string`); +} + +module.exports = { + resolveDataSchema, +}; diff --git a/actions/setup/js/data_schema_normalizer.test.cjs b/actions/setup/js/data_schema_normalizer.test.cjs new file mode 100644 index 00000000000..bf7ec147661 --- /dev/null +++ b/actions/setup/js/data_schema_normalizer.test.cjs @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { resolveDataSchema } from "./data_schema_normalizer.cjs"; + +describe("data_schema_normalizer", () => { + it("accepts shorthand object syntax", () => { + const schema = resolveDataSchema( + { + verdict: "string", + criteria_passed: "number", + }, + "safe-outputs.data" + ); + + expect(schema).toEqual({ + type: "object", + properties: { + verdict: { type: "string" }, + criteria_passed: { type: "number" }, + }, + required: ["criteria_passed", "verdict"], + additionalProperties: false, + }); + }); + + it("accepts JSON string schema syntax", () => { + const schema = resolveDataSchema( + JSON.stringify({ + type: "object", + properties: { + verdict: { type: "string", enum: ["APPROVE", "REJECT"] }, + }, + required: ["verdict"], + additionalProperties: false, + }), + "safe-outputs.data" + ); + + expect(schema.type).toBe("object"); + expect(schema.properties.verdict.enum).toEqual(["APPROVE", "REJECT"]); + expect(schema.additionalProperties).toBe(false); + }); + + it("rejects schemas with unsupported keywords", () => { + expect(() => + resolveDataSchema( + { + type: "object", + properties: { + verdict: { + type: "string", + $ref: "#/$defs/verdict", + }, + }, + }, + "safe-outputs.data" + ) + ).toThrow("unsupported keyword"); + }); + + it("rejects additionalProperties: true for codex compatibility", () => { + expect(() => + resolveDataSchema( + { + type: "object", + properties: { + verdict: "string", + }, + additionalProperties: true, + }, + "safe-outputs.data" + ) + ).toThrow("must be false for OpenAI Codex structured outputs compatibility"); + }); +}); diff --git a/actions/setup/js/log_parser_shared.cjs b/actions/setup/js/log_parser_shared.cjs index f8828c60bb7..bfc1cf2a2c3 100644 --- a/actions/setup/js/log_parser_shared.cjs +++ b/actions/setup/js/log_parser_shared.cjs @@ -1344,6 +1344,15 @@ function formatSafeOutputsPreview(safeOutputsContent, options = {}) { preview.push(""); preview.push(""); } + + if (entry.data !== undefined) { + const dataString = truncateString(JSON.stringify(entry.data, null, 2), 400); + preview.push("**Data:**"); + preview.push("```json"); + preview.push(dataString); + preview.push("```"); + preview.push(""); + } } if (hasMore) { diff --git a/actions/setup/js/log_parser_shared.test.cjs b/actions/setup/js/log_parser_shared.test.cjs index 9ec2a97e6ae..1498fbda732 100644 --- a/actions/setup/js/log_parser_shared.test.cjs +++ b/actions/setup/js/log_parser_shared.test.cjs @@ -2164,6 +2164,22 @@ describe("log_parser_shared.cjs", () => { expect(result).toContain("Preview"); }); + it("should render entry data as JSON code block in markdown mode", async () => { + const { formatSafeOutputsPreview } = await import("./log_parser_shared.cjs"); + + const safeOutputs = JSON.stringify({ + type: "add_comment", + body: "Review complete", + data: { verdict: "APPROVE", criteria_passed: 5 }, + }); + const result = formatSafeOutputsPreview(safeOutputs, { isPlainText: false }); + + expect(result).toContain("**Data:**"); + expect(result).toContain("```json"); + expect(result).toContain('"verdict": "APPROVE"'); + expect(result).toContain('"criteria_passed": 5'); + }); + it("should format multiple entries", async () => { const { formatSafeOutputsPreview } = await import("./log_parser_shared.cjs"); diff --git a/actions/setup/js/mcp_scripts_validation.cjs b/actions/setup/js/mcp_scripts_validation.cjs index e24eecdb3c9..19d5889881c 100644 --- a/actions/setup/js/mcp_scripts_validation.cjs +++ b/actions/setup/js/mcp_scripts_validation.cjs @@ -276,6 +276,10 @@ function validateArgumentsAgainstSchema(args, inputSchema) { return validateSchemaNode(args, inputSchema, "", { skipRequiredAtRoot: true }); } +function validateValueAgainstSchema(value, schema) { + return validateSchemaNode(value, schema, "", { skipRequiredAtRoot: false }); +} + function formatSchemaValidationError(toolName, args, error) { if (toolName === "add_labels" && typeof error?.path === "string" && /^labels\[\d+\]$/.test(error.path) && Array.isArray(args?.labels)) { const index = Number(error.path.match(/^labels\[(\d+)\]$/)?.[1] || -1); @@ -308,6 +312,7 @@ module.exports = { buildStringLengthValidationError, validateStringMinLengths, validateArgumentsAgainstSchema, + validateValueAgainstSchema, formatSchemaValidationError, MAX_STRING_INPUT_BYTES, }; diff --git a/actions/setup/js/safe_output_summary.cjs b/actions/setup/js/safe_output_summary.cjs index 299b3e30842..600c60db937 100644 --- a/actions/setup/js/safe_output_summary.cjs +++ b/actions/setup/js/safe_output_summary.cjs @@ -143,6 +143,15 @@ function generateSafeOutputSummary(options) { // secrecy indicates the confidentiality level of the message content. // integrity indicates the trustworthiness level of the message source. if (message) { + if (message.data !== undefined) { + let renderedData = ""; + try { + renderedData = JSON.stringify(message.data, null, 2); + } catch { + renderedData = String(message.data); + } + summary += `**Data:**\n\`\`\`\`\`\`json\n${renderedData}\n\`\`\`\`\`\`\n\n`; + } if (message.secrecy !== undefined && message.secrecy !== null) { summary += `**Secrecy:** \`${message.secrecy}\`\n\n`; } diff --git a/actions/setup/js/safe_output_summary.test.cjs b/actions/setup/js/safe_output_summary.test.cjs index 8cb086e8c0a..5af93f6ab25 100644 --- a/actions/setup/js/safe_output_summary.test.cjs +++ b/actions/setup/js/safe_output_summary.test.cjs @@ -264,6 +264,32 @@ describe("safe_output_summary", () => { expect(summary).toContain("medium"); }); + it("should render message data as a JSON code region", () => { + const options = { + type: "add_comment", + messageIndex: 2, + success: true, + result: { + repo: "owner/repo", + number: 456, + }, + message: { + body: "A comment", + data: { + verdict: "APPROVE", + criteria_passed: 5, + }, + }, + }; + + const summary = generateSafeOutputSummary(options); + + expect(summary).toContain("**Data:**"); + expect(summary).toContain("``````json"); + expect(summary).toContain('"verdict": "APPROVE"'); + expect(summary).toContain('"criteria_passed": 5'); + }); + it("should use result.body (final posted body) over message.body for body preview", () => { const options = { type: "add_comment", diff --git a/actions/setup/js/safe_output_type_validator.cjs b/actions/setup/js/safe_output_type_validator.cjs index 430a72ba611..0946a63d8c6 100644 --- a/actions/setup/js/safe_output_type_validator.cjs +++ b/actions/setup/js/safe_output_type_validator.cjs @@ -13,6 +13,8 @@ const { sanitizeContent } = require("./sanitize_content.cjs"); const { isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { unfenceMarkdown } = require("./markdown_unfencing.cjs"); +const { validateValueAgainstSchema } = require("./mcp_scripts_validation.cjs"); +const { resolveDataSchema } = require("./data_schema_normalizer.cjs"); /** * Default max body length for GitHub content @@ -27,7 +29,13 @@ const MAX_GITHUB_USERNAME_LENGTH = 39; const ISSUE_INTENT_RATIONALE_MAX_LENGTH = 280; /** - * @typedef {{ allowedAliases?: string[], maxBotMentions?: number, normalizeIssueClosingKeywords?: boolean }} ValidateOptions + * @typedef {{ + * allowedAliases?: string[], + * maxBotMentions?: number, + * normalizeIssueClosingKeywords?: boolean, + * dataEnabled?: boolean, + * dataSchema?: any + * }} ValidateOptions */ // GitHub issue-closing keywords: @@ -40,6 +48,7 @@ const ISSUE_CLOSING_KEYWORD_BACKTICK_PATTERN = new RegExp(`\`(\\b(?:${ISSUE_CLOS const ISSUE_CLOSING_REFERENCE_BACKTICK_PATTERN = new RegExp(`(\\b(?:${ISSUE_CLOSING_KEYWORDS})\\b)(\\s+)\`(${ISSUE_REFERENCE_PATTERN})\``, "gi"); const NORMALIZE_CLOSER_BODY_TYPES = new Set(["create_issue", "add_comment", "create_pull_request"]); const ISSUE_INTENT_LABEL_TYPES = new Set(["add_labels", "remove_labels", "update_issue"]); +const STRUCTURED_DATA_LABEL = "Structured data:"; /** * Remove markdown backticks around recognized issue-closing keyword references. @@ -710,6 +719,72 @@ function validateItem(item, itemType, lineNum, options) { return { isValid: false, error: errors[0] }; // Return first error } + if (item.data !== undefined) { + const runtimeDataSchema = options?.dataSchema; + const runtimeDataEnabled = options?.dataEnabled === true || runtimeDataSchema !== undefined; + const configDataEnabled = typeConfig.dataEnabled === true || typeConfig.dataSchema !== undefined; + const dataEnabled = runtimeDataEnabled || configDataEnabled; + if (!dataEnabled) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data' is not enabled (set safe-outputs.data in workflow frontmatter)`, + }; + } + if (!item.data || typeof item.data !== "object" || Array.isArray(item.data)) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data' must be an object`, + }; + } + + let dataJSON; + let normalizedData; + try { + dataJSON = JSON.stringify(item.data, null, 2); + normalizedData = JSON.parse(dataJSON); + } catch { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data' must be JSON-serializable`, + }; + } + + // Preserve normalized data on the item for downstream automation. + normalizedItem.data = normalizedData; + + const schemaSource = runtimeDataSchema !== undefined ? runtimeDataSchema : typeConfig.dataSchema; + if (schemaSource !== undefined) { + let dataSchema; + try { + dataSchema = resolveDataSchema(schemaSource, `safe-outputs.${itemType}.data`); + } catch (error) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data' schema is invalid: ${getErrorMessage(error)}`, + }; + } + const dataSchemaError = validateValueAgainstSchema(normalizedData, dataSchema); + if (dataSchemaError) { + const errorPath = dataSchemaError.path ? `.${dataSchemaError.path}` : ""; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data'${errorPath} ${dataSchemaError.message}`, + }; + } + } + + // If this safe-output type supports a body field, append structured data + // as fenced JSON so it survives body sanitization. + if (Object.prototype.hasOwnProperty.call(typeConfig.fields, "body")) { + const dataBlock = `${STRUCTURED_DATA_LABEL}\n\`\`\`json\n${dataJSON}\n\`\`\``; + if (typeof normalizedItem.body === "string" && normalizedItem.body.length > 0) { + normalizedItem.body = `${normalizedItem.body}\n\n${dataBlock}`; + } else { + normalizedItem.body = dataBlock; + } + } + } + return { isValid: true, normalizedItem }; } diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs index 6fe0d9877cf..53bba1dee42 100644 --- a/actions/setup/js/safe_output_type_validator.test.cjs +++ b/actions/setup/js/safe_output_type_validator.test.cjs @@ -12,6 +12,7 @@ global.core = mockCore; const SAMPLE_VALIDATION_CONFIG = { create_issue: { defaultMax: 1, + dataEnabled: true, fields: { title: { required: true, type: "string", sanitize: true, maxLength: 128 }, body: { required: true, type: "string", sanitize: true, maxLength: 65000, minLength: 20 }, @@ -22,6 +23,7 @@ const SAMPLE_VALIDATION_CONFIG = { }, add_comment: { defaultMax: 1, + dataEnabled: true, fields: { body: { required: true, type: "string", sanitize: true, maxLength: 65000 }, item_number: { issueOrPRNumber: true }, @@ -410,6 +412,96 @@ describe("safe_output_type_validator", () => { expect(result.normalizedItem.title).toContain("`@mention`"); }); + it("should append structured data as fenced JSON to body fields", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { + type: "add_comment", + body: "Review complete.", + data: { + verdict: "APPROVE", + marker: "", + criteria_passed: 5, + }, + }, + "add_comment", + 1 + ); + + expect(result.isValid).toBe(true); + expect(result.normalizedItem.body).toContain("Review complete."); + expect(result.normalizedItem.body).toContain("Structured data:"); + expect(result.normalizedItem.body).toContain("```json"); + expect(result.normalizedItem.body).toContain('"verdict": "APPROVE"'); + expect(result.normalizedItem.body).toContain('"marker": ""'); + expect(result.normalizedItem.data).toEqual({ + verdict: "APPROVE", + marker: "", + criteria_passed: 5, + }); + }); + + it("should reject data values that are not objects", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "add_comment", body: "Review complete.", data: ["APPROVE"] }, "add_comment", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("'data' must be an object"); + }); + + it("should reject data when not enabled", async () => { + const { validateItem, resetValidationConfigCache } = await import("./safe_output_type_validator.cjs"); + const configWithoutData = JSON.parse(JSON.stringify(SAMPLE_VALIDATION_CONFIG)); + delete configWithoutData.add_comment.dataEnabled; + process.env.GH_AW_VALIDATION_CONFIG = JSON.stringify(configWithoutData); + resetValidationConfigCache(); + + const result = validateItem({ type: "add_comment", body: "Review complete.", data: { verdict: "APPROVE" } }, "add_comment", 1); + expect(result.isValid).toBe(false); + expect(result.error).toContain("'data' is not enabled"); + }); + + it("should enforce data schema when configured", async () => { + const { validateItem, resetValidationConfigCache } = await import("./safe_output_type_validator.cjs"); + const configWithDataSchema = JSON.parse(JSON.stringify(SAMPLE_VALIDATION_CONFIG)); + configWithDataSchema.add_comment.dataSchema = { + type: "object", + properties: { + verdict: { type: "string", enum: ["APPROVE", "REJECT"] }, + criteria_passed: { type: "number" }, + }, + required: ["verdict"], + additionalProperties: false, + }; + process.env.GH_AW_VALIDATION_CONFIG = JSON.stringify(configWithDataSchema); + resetValidationConfigCache(); + + const invalid = validateItem({ type: "add_comment", body: "Review complete.", data: { verdict: "APPROVE", criteria_passed: 5, extra: "x" } }, "add_comment", 1); + expect(invalid.isValid).toBe(false); + expect(invalid.error).toContain("'data'.extra"); + + const valid = validateItem({ type: "add_comment", body: "Review complete.", data: { verdict: "APPROVE", criteria_passed: 5 } }, "add_comment", 1); + expect(valid.isValid).toBe(true); + }); + + it("should enforce runtime data schema supplied as JSON string", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const invalid = validateItem({ type: "add_comment", body: "Review complete.", data: { verdict: "APPROVE", extra: "x" } }, "add_comment", 1, { + dataEnabled: true, + dataSchema: JSON.stringify({ + type: "object", + properties: { verdict: { type: "string" } }, + required: ["verdict"], + additionalProperties: false, + }), + }); + expect(invalid.isValid).toBe(false); + expect(invalid.error).toContain("'data'.extra"); + }); + it("should normalize a backticked issue reference when enabled", async () => { const { validateItem } = await import("./safe_output_type_validator.cjs"); @@ -1255,12 +1347,12 @@ describe("safe_output_type_validator", () => { type: "create_issue", title: "Test", body: "Detailed issue body text.", - metadata: { project: "test" }, + data: { project: "test" }, }; const result = validateItem(item, "create_issue", 1); expect(result.isValid).toBe(true); - expect(result.normalizedItem.metadata).toEqual({ project: "test" }); + expect(result.normalizedItem.data).toEqual({ project: "test" }); }); }); }); diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 0af94ff315e..553e3c74a3f 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -28,6 +28,8 @@ const { validateCreatePullRequestIntent, validatePushToPullRequestBranchIntent, const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); const { lstatGuard } = require("./symlink_guard.cjs"); +const { validateValueAgainstSchema } = require("./mcp_scripts_validation.cjs"); +const { resolveDataSchema } = require("./data_schema_normalizer.cjs"); /** PR event names used for target:triggering context validation across all safe-output handlers. */ const PR_EVENT_NAMES = new Set(["pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment"]); @@ -373,6 +375,28 @@ function createHandlers(server, appendSafeOutput, config = {}) { */ const defaultHandler = type => args => { const entry = { ...(args || {}), type }; + if (entry.data !== undefined) { + const toolConfig = getSafeOutputsToolConfig(config, type); + const dataEnabled = toolConfig?.data_enabled === true || (toolConfig?.data_schema && typeof toolConfig.data_schema === "object"); + if (!dataEnabled) { + return buildIntentErrorResponse(`${type} data is not enabled (set safe-outputs.data in workflow frontmatter)`); + } + let dataSchema = null; + try { + if (toolConfig?.data_schema !== undefined) { + dataSchema = resolveDataSchema(toolConfig.data_schema, `safe-outputs.${type}.data`); + } + } catch (error) { + return buildIntentErrorResponse(`${type} data schema is invalid: ${getErrorMessage(error)}`); + } + if (dataSchema) { + const dataSchemaError = validateValueAgainstSchema(entry.data, dataSchema); + if (dataSchemaError) { + const errorPath = dataSchemaError.path ? `.${dataSchemaError.path}` : ""; + return buildIntentErrorResponse(`${type} data${errorPath} ${dataSchemaError.message}`); + } + } + } const wildcardTargetValidationError = validateWildcardTargetRequirement(entry); if (wildcardTargetValidationError) { return wildcardTargetValidationError; diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index 313a1757b96..cb987cd142d 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -210,6 +210,60 @@ describe("safe_outputs_handlers", () => { expect(mockAppendSafeOutput).toHaveBeenCalledWith({ type: "test-type" }); expect(result.content[0].text).toBe(JSON.stringify({ result: "success" })); }); + + it("should enforce data_schema for default handler payloads", () => { + const handlersWithSchema = createHandlers(mockServer, mockAppendSafeOutput, { + add_comment: { + data_enabled: true, + data_schema: { + type: "object", + properties: { + verdict: { type: "string" }, + }, + required: ["verdict"], + additionalProperties: false, + }, + }, + }); + const handler = handlersWithSchema.defaultHandler("add_comment"); + + const result = handler({ body: "ok", data: { verdict: "APPROVE", extra: "nope" } }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("data.extra"); + expect(mockAppendSafeOutput).not.toHaveBeenCalled(); + }); + + it("should reject data when not enabled in handler config", () => { + const handler = handlers.defaultHandler("add_comment"); + const result = handler({ body: "ok", data: { verdict: "APPROVE" } }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("data is not enabled"); + expect(mockAppendSafeOutput).not.toHaveBeenCalled(); + }); + + it("should enforce JSON-string data_schema in handler config", () => { + const handlersWithSchema = createHandlers(mockServer, mockAppendSafeOutput, { + add_comment: { + data_enabled: true, + data_schema: JSON.stringify({ + type: "object", + properties: { + verdict: { type: "string" }, + }, + required: ["verdict"], + additionalProperties: false, + }), + }, + }); + const handler = handlersWithSchema.defaultHandler("add_comment"); + + const result = handler({ body: "ok", data: { verdict: "APPROVE", extra: "nope" } }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("data.extra"); + expect(mockAppendSafeOutput).not.toHaveBeenCalled(); + }); }); describe("uploadAssetHandler", () => { diff --git a/actions/setup/js/safe_outputs_mcp_schema_validation.test.cjs b/actions/setup/js/safe_outputs_mcp_schema_validation.test.cjs index cf59e37f320..fd5adb9e9c7 100644 --- a/actions/setup/js/safe_outputs_mcp_schema_validation.test.cjs +++ b/actions/setup/js/safe_outputs_mcp_schema_validation.test.cjs @@ -22,6 +22,28 @@ describe("Safe Outputs MCP Schema Validation", () => { const toolsContent = fs.readFileSync(toolsPath, "utf8"); tools = JSON.parse(toolsContent); + function resolveSchemaProperty(property) { + if (!property || !property.$ref || typeof property.$ref !== "string") { + return property; + } + + if (!property.$ref.startsWith("#/")) { + return property; + } + + const refPath = property.$ref.slice(2).split("/"); + let resolved = tools; + for (const segment of refPath) { + if (resolved && Object.prototype.hasOwnProperty.call(resolved, segment)) { + resolved = resolved[segment]; + continue; + } + return property; + } + + return resolved && typeof resolved === "object" ? resolved : property; + } + describe("Schema Completeness", () => { it("should load tools schema successfully", () => { expect(tools).toBeDefined(); @@ -206,7 +228,7 @@ describe("Safe Outputs MCP Schema Validation", () => { const optionalFields = allFields.filter(field => !requiredFields.includes(field)); optionalFields.forEach(field => { - const property = schema.properties[field]; + const property = resolveSchemaProperty(schema.properties[field]); if (!property.description || property.description.trim() === "") { missingDescriptions.push({ @@ -278,8 +300,10 @@ describe("Safe Outputs MCP Schema Validation", () => { const properties = tool.inputSchema.properties; Object.entries(properties).forEach(([fieldName, property]) => { + const resolvedProperty = resolveSchemaProperty(property); + // Check that type is defined - if (!property.type) { + if (!resolvedProperty.type) { inconsistentTypes.push({ tool: tool.name, field: fieldName, @@ -288,8 +312,8 @@ describe("Safe Outputs MCP Schema Validation", () => { } // Check for array types - if (property.type === "array") { - if (!property.items) { + if (resolvedProperty.type === "array") { + if (!resolvedProperty.items) { inconsistentTypes.push({ tool: tool.name, field: fieldName, @@ -299,10 +323,10 @@ describe("Safe Outputs MCP Schema Validation", () => { } // Check for union types (multiple types) - if (Array.isArray(property.type)) { + if (Array.isArray(resolvedProperty.type)) { // Union types are valid but should be intentional // Just verify they're not empty - if (property.type.length === 0) { + if (resolvedProperty.type.length === 0) { inconsistentTypes.push({ tool: tool.name, field: fieldName, diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 3fc78921dc6..90d3ad81255 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -1,20 +1,27 @@ [ { "name": "create_issue", - "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema — required fields (title, body) are listed in this schema; if you are not ready to open the real issue, call `noop` instead. Creates a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. Compatibility: labels may be passed as either an array of strings or a comma-separated string; string input is split, trimmed, and normalized to an array.", + "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 required fields (title, body) are listed in this schema; if you are not ready to open the real issue, call `noop` instead. Creates a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. Compatibility: labels may be passed as either an array of strings or a comma-separated string; string input is split, trimmed, and normalized to an array.", "inputSchema": { "type": "object", "required": ["title", "body"], + "$defs": { + "structured_data": { + "type": "object", + "description": "Optional structured data to carry machine-readable context through sanitization-safe channels. When provided, this object is preserved and appended to the body as fenced JSON.", + "additionalProperties": true + } + }, "properties": { "title": { "type": "string", - "description": "Concise issue title summarizing the bug, feature, or task. Must be the final intended title — not a placeholder or test value. The title appears as the main heading, so keep it brief and descriptive." + "description": "Concise issue title summarizing the bug, feature, or task. Must be the final intended title \u2014 not a placeholder or test value. The title appears as the main heading, so keep it brief and descriptive." }, "body": { "type": "string", "minLength": 20, "maxLength": 65536, - "description": "Detailed issue description in Markdown. Must be the final intended body — not a placeholder or test value. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate." + "description": "Detailed issue description in Markdown. Must be the final intended body \u2014 not a placeholder or test value. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate." }, "labels": { "type": ["array", "string"], @@ -44,12 +51,12 @@ }, "parent": { "type": ["number", "string"], - "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'." + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'." }, "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Use this same '#aw_ID' form in body text to cross-reference the issue; these references are replaced with the real issue number after creation.", + "description": "Unique temporary identifier for this issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Use this same '#aw_ID' form in body text to cross-reference the issue; these references are replaced with the real issue number after creation.", "x-synonyms": ["temporaryId"] }, "secrecy": { @@ -285,7 +292,7 @@ }, { "name": "add_comment", - "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema — the required `body` field is listed in this schema; if you are not ready to post a real comment, call `noop` instead. Adds a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool does not require discussions:write permission. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission.", + "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 the required `body` field is listed in this schema; if you are not ready to post a real comment, call `noop` instead. Adds a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool does not require discussions:write permission. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission.", "inputSchema": { "type": "object", "required": ["body"], @@ -293,11 +300,11 @@ "body": { "type": "string", "maxLength": 65536, - "description": "The comment text in Markdown format. Must be the final intended comment — not a placeholder or test value. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation. CONSTRAINTS: The complete comment (your body text + automatically added footer) must not exceed 65536 characters total. Maximum 10 mentions (@username), maximum 50 links (http/https URLs). A footer (~200-500 characters) is automatically appended with workflow attribution, so leave adequate space. If these limits are exceeded, the tool call will fail with a detailed error message indicating which constraint was violated." + "description": "The comment text in Markdown format. Must be the final intended comment \u2014 not a placeholder or test value. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation. CONSTRAINTS: The complete comment (your body text + automatically added footer) must not exceed 65536 characters total. Maximum 10 mentions (@username), maximum 50 links (http/https URLs). A footer (~200-500 characters) is automatically appended with workflow attribution, so leave adequate space. If these limits are exceeded, the tool call will fail with a detailed error message indicating which constraint was violated." }, "item_number": { "type": ["number", "string"], - "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, the tool auto-targets the issue, PR, or discussion that triggered this workflow. Auto-targeting only works for issue, pull_request, discussion, and comment event triggers — it does NOT work for schedule, workflow_dispatch, push, or workflow_run triggers. For those trigger types, always provide item_number explicitly, or the tool call will fail with an error. Required when safe-outputs.add-comment.target is '*' (any item): calls without item_number (or pr_number/pr alias) are rejected. NOTE: this field is named item_number, NOT issue_number.", + "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, the tool auto-targets the issue, PR, or discussion that triggered this workflow. Auto-targeting only works for issue, pull_request, discussion, and comment event triggers \u2014 it does NOT work for schedule, workflow_dispatch, push, or workflow_run triggers. For those trigger types, always provide item_number explicitly, or the tool call will fail with an error. Required when safe-outputs.add-comment.target is '*' (any item): calls without item_number (or pr_number/pr alias) are rejected. NOTE: this field is named item_number, NOT issue_number.", "x-synonyms": ["issue_number", "itemNumber"] }, "pr_number": { @@ -312,12 +319,12 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this comment. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Auto-generated if not provided. The temporary ID is returned in the tool response so you can reference this comment later.", + "description": "Unique temporary identifier for this comment. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Auto-generated if not provided. The temporary ID is returned in the tool response so you can reference this comment later.", "x-synonyms": ["temporaryId"] }, "reply_to_id": { "type": "string", - "description": "Node ID of the discussion comment to reply to, enabling threaded discussion comments. When provided, the new comment is posted as a reply to the specified top-level discussion comment. If the given node ID belongs to a nested reply, the handler automatically resolves it to the top-level parent. Only applicable for discussion comments — ignored for issue and pull request comments.", + "description": "Node ID of the discussion comment to reply to, enabling threaded discussion comments. When provided, the new comment is posted as a reply to the specified top-level discussion comment. If the given node ID belongs to a nested reply, the handler automatically resolves it to the top-level parent. Only applicable for discussion comments \u2014 ignored for issue and pull request comments.", "x-synonyms": ["replyToId"] }, "comment_id": { @@ -390,7 +397,7 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.", + "description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.", "x-synonyms": ["temporaryId"] }, "secrecy": { @@ -427,7 +434,7 @@ }, "pull_request_number": { "type": ["number", "string"], - "description": "Pull request number to add the review comment to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds the comment to the PR that triggered this workflow. Required when the workflow target is '*' (any PR) — omitting it will cause the comment to fail.", + "description": "Pull request number to add the review comment to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds the comment to the PR that triggered this workflow. Required when the workflow target is '*' (any PR) \u2014 omitting it will cause the comment to fail.", "x-synonyms": ["pullRequestNumber"] }, "start_line": { @@ -481,7 +488,7 @@ }, "pull_request_number": { "type": ["number", "string"], - "description": "Pull request number to submit the review on. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, submits the review on the PR that triggered this workflow. Required when the workflow target is '*' (any PR) — omitting it will cause the review to fail.", + "description": "Pull request number to submit the review on. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, submits the review on the PR that triggered this workflow. Required when the workflow target is '*' (any PR) \u2014 omitting it will cause the review to fail.", "x-synonyms": ["pullRequestNumber"] }, "repo": { @@ -698,12 +705,12 @@ } ] }, - "description": "Labels to add (e.g., ['bug', 'priority-high']). Each entry can be either a label name string or an object with name plus optional rationale/confidence/suggest intent metadata. Labels must exist in the repository. This field is required — omitting it will cause a validation error." + "description": "Labels to add (e.g., ['bug', 'priority-high']). Each entry can be either a label name string or an object with name plus optional rationale/confidence/suggest intent metadata. Labels must exist in the repository. This field is required \u2014 omitting it will cause a validation error." }, "item_number": { "type": ["number", "string"], "pattern": "^(\\d+|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Issue or PR number to add labels to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, adds labels to the issue or PR that triggered this workflow. Only works for issue or pull_request event triggers. For schedule, workflow_dispatch, or other triggers, item_number is required — omitting it will silently skip the label operation.", + "description": "Issue or PR number to add labels to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, adds labels to the issue or PR that triggered this workflow. Only works for issue or pull_request event triggers. For schedule, workflow_dispatch, or other triggers, item_number is required \u2014 omitting it will silently skip the label operation.", "x-synonyms": ["itemNumber"] }, "secrecy": { @@ -764,7 +771,7 @@ "item_number": { "type": ["number", "string"], "pattern": "^(\\d+|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Issue or PR number to remove labels from. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, removes labels from the item that triggered this workflow.", + "description": "Issue or PR number to remove labels from. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, removes labels from the item that triggered this workflow.", "x-synonyms": ["itemNumber"] }, "secrecy": { @@ -803,7 +810,7 @@ }, "pull_request_number": { "type": ["number", "string"], - "description": "Pull request number to add reviewers to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds reviewers to the PR that triggered this workflow. Only works for pull_request event triggers. For workflow_dispatch, schedule, or other triggers, pull_request_number is required — omitting it will silently skip the reviewer assignment.", + "description": "Pull request number to add reviewers to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds reviewers to the PR that triggered this workflow. Only works for pull_request event triggers. For workflow_dispatch, schedule, or other triggers, pull_request_number is required \u2014 omitting it will silently skip the reviewer assignment.", "x-synonyms": ["pullRequestNumber"] }, "secrecy": { @@ -827,7 +834,7 @@ "properties": { "issue_number": { "type": ["number", "string"], - "description": "Issue number to assign to the milestone. This is the numeric ID from the GitHub URL (e.g., 567 in github.com/owner/repo/issues/567). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'.", + "description": "Issue number to assign to the milestone. This is the numeric ID from the GitHub URL (e.g., 567 in github.com/owner/repo/issues/567). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'.", "x-synonyms": ["issueNumber"] }, "milestone_number": { @@ -837,7 +844,7 @@ }, "milestone_title": { "type": "string", - "description": "Milestone title to assign the issue to (e.g., \"v1.0\"). Used as an alternative to milestone_number — the handler looks up the milestone by title. Either milestone_number or milestone_title must be provided.", + "description": "Milestone title to assign the issue to (e.g., \"v1.0\"). Used as an alternative to milestone_number \u2014 the handler looks up the milestone by title. Either milestone_number or milestone_title must be provided.", "x-synonyms": ["milestoneTitle"] }, "secrecy": { @@ -860,7 +867,7 @@ "properties": { "issue_number": { "type": ["number", "string"], - "description": "Issue number to assign the Copilot coding agent to. This is the numeric ID from the GitHub URL (e.g., 234 in github.com/owner/repo/issues/234). Can also be a temporary_id from an issue created earlier in the same workflow run — use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. The issue should contain clear, actionable requirements. Either issue_number or pull_number must be provided, but not both.", + "description": "Issue number to assign the Copilot coding agent to. This is the numeric ID from the GitHub URL (e.g., 234 in github.com/owner/repo/issues/234). Can also be a temporary_id from an issue created earlier in the same workflow run \u2014 use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. The issue should contain clear, actionable requirements. Either issue_number or pull_number must be provided, but not both.", "x-synonyms": ["issueNumber"] }, "pull_number": { @@ -1199,7 +1206,7 @@ }, "branch": { "type": "string", - "description": "The local branch name that contains the committed changes to push (e.g., \"feature/my-fix\"). Providing this explicitly prevents race conditions in batch workflows where the working tree may have been checked out to a different PR's branch between commit and tool-call time. When omitted, the branch is inferred from the current git HEAD — only safe for single-PR workflows." + "description": "The local branch name that contains the committed changes to push (e.g., \"feature/my-fix\"). Providing this explicitly prevents race conditions in batch workflows where the working tree may have been checked out to a different PR's branch between commit and tool-call time. When omitted, the branch is inferred from the current git HEAD \u2014 only safe for single-PR workflows." }, "pull_request_number": { "type": ["number", "string"], @@ -1281,7 +1288,7 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Optional temporary identifier for this artifact upload. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_chart1', '#aw_img_out'. The bare 'aw_chart1' form is also accepted. Declare this ID here if you plan to embed the artifact URL in a subsequent message body using '#aw_ID' — for example '![chart](#aw_chart1)' in a create_discussion body. The safe-outputs processor replaces '#aw_ID' references with the actual artifact download URL after upload. When skip-archive is true the URL points directly to the file and is suitable for inline images.", + "description": "Optional temporary identifier for this artifact upload. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_chart1', '#aw_img_out'. The bare 'aw_chart1' form is also accepted. Declare this ID here if you plan to embed the artifact URL in a subsequent message body using '#aw_ID' \u2014 for example '![chart](#aw_chart1)' in a create_discussion body. The safe-outputs processor replaces '#aw_ID' references with the actual artifact download URL after upload. When skip-archive is true the URL points directly to the file and is suitable for inline images.", "x-synonyms": ["temporaryId"] }, "secrecy": { @@ -1314,7 +1321,7 @@ }, "body": { "type": "string", - "description": "Release body content in Markdown. Must be the final intended content — not a placeholder or test value. For 'replace', this becomes the entire release body. For 'append'/'prepend', this is added with a separator.", + "description": "Release body content in Markdown. Must be the final intended content \u2014 not a placeholder or test value. For 'replace', this becomes the entire release body. For 'append'/'prepend', this is added with a separator.", "minLength": 20, "maxLength": 65536 }, @@ -1492,7 +1499,7 @@ }, { "name": "set_issue_field", - "description": "Set a single GitHub issue custom field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name). Does NOT support builtin issue fields such as \"title\", \"body\", or \"state\" — use the update_issue tool for those (for open/closed state, use update_issue.status).", + "description": "Set a single GitHub issue custom field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name). Does NOT support builtin issue fields such as \"title\", \"body\", or \"state\" \u2014 use the update_issue tool for those (for open/closed state, use update_issue.status).", "inputSchema": { "type": "object", "required": ["value"], @@ -1552,7 +1559,7 @@ "project": { "type": "string", "pattern": "^(https://github\\.com/(orgs|users)/[^/]+/projects/\\d+|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'), or a temporary project ID from a recent create_project call — use '#aw_abc1' (canonical) or bare 'aw_abc1' (also accepted). Project names or numbers alone are NOT accepted." + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'), or a temporary project ID from a recent create_project call \u2014 use '#aw_abc1' (canonical) or bare 'aw_abc1' (also accepted). Project names or numbers alone are NOT accepted." }, "operation": { "type": "string", @@ -1567,7 +1574,7 @@ }, "content_number": { "type": ["number", "string"], - "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456), or a temporary ID from a recent create_issue call — use '#aw_abc123' (canonical); bare 'aw_abc123' is also accepted. Required when content_type is 'issue' or 'pull_request'.", + "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456), or a temporary ID from a recent create_issue call \u2014 use '#aw_abc123' (canonical); bare 'aw_abc123' is also accepted. Required when content_type is 'issue' or 'pull_request'.", "x-synonyms": ["contentNumber"] }, "target_repo": { @@ -1590,13 +1597,13 @@ "draft_issue_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Temporary ID of an existing draft issue to update — use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted. Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates.", + "description": "Temporary ID of an existing draft issue to update \u2014 use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted. Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates.", "x-synonyms": ["draftIssueId"] }, "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this draft issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. Provide this when creating a new draft to enable future updates via draft_issue_id.", + "description": "Unique temporary identifier for this draft issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. Provide this when creating a new draft to enable future updates via draft_issue_id.", "x-synonyms": ["temporaryId"] }, "fields": { @@ -1711,7 +1718,7 @@ }, { "name": "report_incomplete", - "description": "Signal that the task could not be completed due to an infrastructure or tool failure (e.g., MCP server crash, missing authentication, inaccessible repository). Use this when required tools or data are unavailable and the task cannot be meaningfully performed. This is distinct from noop (no action needed) — it indicates an active failure that prevented the task from running. Provide a specific reason and optional details so downstream issue aggregation can preserve complete incomplete-signal context. The workflow framework will treat this as a failure signal even when the agent exits successfully.", + "description": "Signal that the task could not be completed due to an infrastructure or tool failure (e.g., MCP server crash, missing authentication, inaccessible repository). Use this when required tools or data are unavailable and the task cannot be meaningfully performed. This is distinct from noop (no action needed) \u2014 it indicates an active failure that prevented the task from running. Provide a specific reason and optional details so downstream issue aggregation can preserve complete incomplete-signal context. The workflow framework will treat this as a failure signal even when the agent exits successfully.", "inputSchema": { "type": "object", "required": ["reason"], @@ -1753,13 +1760,13 @@ "item_url": { "type": "string", "pattern": "^(https://github\\\\.com/[^/]+/[^/]+/issues/(\\\\d+|#?aw_[A-Za-z0-9_]{3,12})|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Optional GitHub issue URL or temporary ID to add as the first item to the project. Accepts either a full URL (e.g., 'https://github.com/owner/repo/issues/123'), a URL with temporary ID (e.g., 'https://github.com/owner/repo/issues/#aw_abc1'), or a plain temporary ID — use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted.", + "description": "Optional GitHub issue URL or temporary ID to add as the first item to the project. Accepts either a full URL (e.g., 'https://github.com/owner/repo/issues/123'), a URL with temporary ID (e.g., 'https://github.com/owner/repo/issues/#aw_abc1'), or a plain temporary ID \u2014 use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted.", "x-synonyms": ["itemUrl"] }, "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Optional temporary identifier for this project. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. If not provided, one will be auto-generated and returned in the response. Use this same '#aw_ID' form in add_project_item to reference this project.", + "description": "Optional temporary identifier for this project. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. If not provided, one will be auto-generated and returned in the response. Use this same '#aw_ID' form in add_project_item to reference this project.", "x-synonyms": ["temporaryId"] }, "secrecy": { @@ -1903,7 +1910,7 @@ }, { "name": "create_check_run", - "description": "Create a GitHub Check Run to report agent analysis results on a commit or pull request. Check Runs appear in the PR checks UI and on commits with a pass/fail status. Use this to surface structured analysis results as a first-class GitHub check. The check run name is configured in the workflow frontmatter and is NOT accepted as a parameter — do not pass name. When `safe-outputs.create-check-run.target` is configured, pull request targeting follows standard PR target rules. With `target: \"*\"`, include `pull_request_number` (or `pr_number`/`pr`/`pull_number`) in each call.", + "description": "Create a GitHub Check Run to report agent analysis results on a commit or pull request. Check Runs appear in the PR checks UI and on commits with a pass/fail status. Use this to surface structured analysis results as a first-class GitHub check. The check run name is configured in the workflow frontmatter and is NOT accepted as a parameter \u2014 do not pass name. When `safe-outputs.create-check-run.target` is configured, pull request targeting follows standard PR target rules. With `target: \"*\"`, include `pull_request_number` (or `pr_number`/`pr`/`pull_number`) in each call.", "inputSchema": { "type": "object", "required": ["conclusion", "title", "summary"], diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 08434781ecb..62a4d4d0273 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1716,6 +1716,34 @@ Validation rules: The text output by AI agents is automatically sanitized to prevent injection of malicious content and ensure safe rendering on GitHub. The auto-sanitization applied is: XML escaped, HTTPS only, domain allowlist (GitHub by default), 0.5MB/65k line limits, control char stripping. +HTML/XML comments (``) are removed from sanitized body fields. +If you need a machine-readable channel that survives sanitization, configure `safe-outputs.data` in frontmatter: + +```yaml wrap +safe-outputs: + data: false # default; reject output `data` + # data: true # allow any object in output `data` + # data: # enforce inline schema for output `data` + # verdict: string + # score: number + # data: ${{ fromJSON(needs.schema.outputs.data_schema) }} # runtime schema expression +``` + +Inline object schemas are validated at compile-time (Go) and runtime (JavaScript). Expression-based schemas are resolved and validated at runtime in JavaScript. + +For safe outputs that support `body`, the validator preserves output `data` and appends it to the body as fenced JSON: + +```json +{ + "type": "add_comment", + "body": "Review complete. All criteria pass.", + "data": { + "verdict": "APPROVE", + "criteria_passed": 5 + } +} +``` + You can configure sanitization options: ```yaml wrap diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md index e5471b80e94..a5af862cdb9 100644 --- a/docs/src/content/docs/specs/safe-outputs-specification.md +++ b/docs/src/content/docs/specs/safe-outputs-specification.md @@ -1417,6 +1417,50 @@ The `["*"]` behavior MUST apply to activation-job token minting and to subsequen In `workflow_call` and other reusable-workflow scenarios, conforming implementations MUST preserve the `["*"]` behavior so that activation can read agent configuration from the callee repository when the App installation grant permits it. +#### GP6: data + +**Syntax**: `safe-outputs.data: false | true | | ` + +**Default**: `false` (disabled) + +**Semantics**: Controls whether body-capable safe output types MAY include a top-level `data` object, and optionally enforces the schema of that object. + +**Modes**: + +1. `false` or omitted: `data` MUST be rejected. +2. `true`: `data` MUST be allowed and MUST be an object. +3. ``: `data` MUST be allowed and MUST satisfy the normalized simplified schema. +4. ``: schema resolution MUST occur at runtime in JavaScript and the resolved schema MUST satisfy this section before `data` validation. + +**Conformance Requirement GP6-1: Accepted Frontmatter Shapes** + +Implementations MUST accept exactly the four syntactic forms above. Non-boolean scalar literals other than GitHub Actions expressions (for example, `data: "schema.json"`) MUST be rejected. + +**Conformance Requirement GP6-2: Simplified Schema Grammar** + +When `` is used, implementations MUST support this simplified schema syntax: + +- Allowed keywords: `type`, `description`, `properties`, `required`, `items`, `enum`, `additionalProperties`, `minLength`, `maxLength`, `minimum`, `maximum`, `pattern` +- Supported primitive type names: `object`, `array`, `string`, `number`, `integer`, `boolean` +- Shorthand property syntax: object literals without schema keywords MUST be interpreted as: + - `type: object` + - `properties: ` + +**Conformance Requirement GP6-3: OpenAI Codex Structured Outputs Compatibility** + +For every object schema node, implementations MUST: + +1. Set `additionalProperties: false` (or reject if explicitly `true`). +2. Require every declared property in `required` (lexical ordering of `required` entries is RECOMMENDED for deterministic output). + +**Conformance Requirement GP6-4: Runtime Validation Placement** + +Compile-time validation SHOULD be applied when schema content is statically available. Runtime validation in JavaScript MUST be applied before operation execution for expression-resolved schemas and for handler-side enforcement. + +**Data Validation Errors** + +When `data` validation fails, implementations MUST return actionable field-path errors that identify the failing location (for example, `data.extra` or `data.required[0]`). + ### 5.3 Type-Specific Common Parameters Every safe output type supports these parameters: diff --git a/pkg/parser/schema_compiler.go b/pkg/parser/schema_compiler.go index 7c269df467f..afbde166104 100644 --- a/pkg/parser/schema_compiler.go +++ b/pkg/parser/schema_compiler.go @@ -153,6 +153,7 @@ func compileSchema(schemaJSON, schemaURL string) (*jsonschema.Schema, error) { // These are used for configuration, not for defining safe output operations. var safeOutputMetaFields = map[string]bool{ "allowed-domains": true, + "data": true, "staged": true, "env": true, "github-token": true, diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 972da70bd1f..1790466fa5f 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5115,6 +5115,23 @@ "type": "string" } }, + "data": { + "description": "Structured data configuration for body-based safe outputs. Set false (or omit) to disable data, true to allow any object data, provide an inline schema object to enforce shape, or provide a GitHub Actions expression string that resolves to one of those forms at runtime.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "additionalProperties": true + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$", + "description": "GitHub Actions expression resolving to false, true, or a JSON object schema at runtime" + } + ] + }, "allowed-github-references": { "type": "array", "description": "List of allowed repositories for GitHub references (e.g., #123 or owner/repo#456). Use 'repo' to allow current repository. References to other repositories will be escaped with backticks. If not specified, all references are allowed.", diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 93e1c9d0978..53f0eb7abcc 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -167,6 +167,7 @@ func (c *Compiler) validateCoreToolConfiguration(workflowData *WorkflowData, mar {logMessage: "Validating sandbox configuration", validateFn: func() error { return validateSandboxConfig(workflowData) }}, {logMessage: "Validating safe-outputs target fields", validateFn: func() error { return validateSafeOutputsTarget(workflowData.SafeOutputs) }}, {logMessage: "Validating safe-outputs max fields", validateFn: func() error { return validateSafeOutputsMax(workflowData.SafeOutputs) }}, + {logMessage: "Validating safe-outputs data schema", validateFn: func() error { return validateSafeOutputsDataSchema(workflowData.SafeOutputs) }}, {logMessage: "Validating safe-outputs samples entries against MCP tool schemas", validateFn: func() error { return validateSafeOutputsSamples(workflowData.SafeOutputs) }}, {logMessage: "Validating safe-outputs urls policy", validateFn: func() error { return validateSafeOutputsURLs(workflowData.SafeOutputs) }}, {logMessage: "Validating safe-outputs allowed-domains", validateFn: func() error { return c.validateSafeOutputsAllowedDomains(workflowData.SafeOutputs) }}, diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 8ff1bc7124c..33045c6cce5 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -1,23 +1,30 @@ [ { "name": "create_issue", - "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema — required fields (title, body) are listed in this schema; if you are not ready to open the real issue, call `noop` instead. Creates a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. Compatibility: labels may be passed as either an array of strings or a comma-separated string; string input is split, trimmed, and normalized to an array.", + "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 required fields (title, body) are listed in this schema; if you are not ready to open the real issue, call `noop` instead. Creates a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. Compatibility: labels may be passed as either an array of strings or a comma-separated string; string input is split, trimmed, and normalized to an array.", "inputSchema": { "type": "object", "required": [ "title", "body" ], + "$defs": { + "structured_data": { + "type": "object", + "description": "Optional structured data to carry machine-readable context through sanitization-safe channels. When provided, this object is preserved and appended to the body as fenced JSON.", + "additionalProperties": true + } + }, "properties": { "title": { "type": "string", - "description": "Concise issue title summarizing the bug, feature, or task. Must be the final intended title — not a placeholder or test value. The title appears as the main heading, so keep it brief and descriptive." + "description": "Concise issue title summarizing the bug, feature, or task. Must be the final intended title \u2014 not a placeholder or test value. The title appears as the main heading, so keep it brief and descriptive." }, "body": { "type": "string", "minLength": 20, "maxLength": 65536, - "description": "Detailed issue description in Markdown. Must be the final intended body — not a placeholder or test value. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate." + "description": "Detailed issue description in Markdown. Must be the final intended body \u2014 not a placeholder or test value. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate." }, "labels": { "type": [ @@ -59,12 +66,12 @@ "number", "string" ], - "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'." + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'." }, "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Use this same '#aw_ID' form in body text to cross-reference the issue; these references are replaced with the real issue number after creation.", + "description": "Unique temporary identifier for this issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Use this same '#aw_ID' form in body text to cross-reference the issue; these references are replaced with the real issue number after creation.", "x-synonyms": [ "temporaryId" ] @@ -354,7 +361,7 @@ }, { "name": "add_comment", - "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema — the required `body` field is listed in this schema; if you are not ready to post a real comment, call `noop` instead. Adds a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool does not require discussions:write permission. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission.", + "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 the required `body` field is listed in this schema; if you are not ready to post a real comment, call `noop` instead. Adds a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool does not require discussions:write permission. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission.", "inputSchema": { "type": "object", "required": [ @@ -364,14 +371,14 @@ "body": { "type": "string", "maxLength": 65536, - "description": "The comment text in Markdown format. Must be the final intended comment — not a placeholder or test value. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation. CONSTRAINTS: The complete comment (your body text + automatically added footer) must not exceed 65536 characters total. Maximum 10 mentions (@username), maximum 50 links (http/https URLs). A footer (~200-500 characters) is automatically appended with workflow attribution, so leave adequate space. If these limits are exceeded, the tool call will fail with a detailed error message indicating which constraint was violated." + "description": "The comment text in Markdown format. Must be the final intended comment \u2014 not a placeholder or test value. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation. CONSTRAINTS: The complete comment (your body text + automatically added footer) must not exceed 65536 characters total. Maximum 10 mentions (@username), maximum 50 links (http/https URLs). A footer (~200-500 characters) is automatically appended with workflow attribution, so leave adequate space. If these limits are exceeded, the tool call will fail with a detailed error message indicating which constraint was violated." }, "item_number": { "type": [ "number", "string" ], - "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, the tool auto-targets the issue, PR, or discussion that triggered this workflow. Auto-targeting only works for issue, pull_request, discussion, and comment event triggers — it does NOT work for schedule, workflow_dispatch, push, or workflow_run triggers. For those trigger types, always provide item_number explicitly, or the tool call will fail with an error. Required when safe-outputs.add-comment.target is '*' (any item): calls without item_number (or pr_number/pr alias) are rejected. NOTE: this field is named item_number, NOT issue_number.", + "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, the tool auto-targets the issue, PR, or discussion that triggered this workflow. Auto-targeting only works for issue, pull_request, discussion, and comment event triggers \u2014 it does NOT work for schedule, workflow_dispatch, push, or workflow_run triggers. For those trigger types, always provide item_number explicitly, or the tool call will fail with an error. Required when safe-outputs.add-comment.target is '*' (any item): calls without item_number (or pr_number/pr alias) are rejected. NOTE: this field is named item_number, NOT issue_number.", "x-synonyms": [ "issue_number", "itemNumber" @@ -397,14 +404,14 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this comment. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Auto-generated if not provided. The temporary ID is returned in the tool response so you can reference this comment later.", + "description": "Unique temporary identifier for this comment. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Auto-generated if not provided. The temporary ID is returned in the tool response so you can reference this comment later.", "x-synonyms": [ "temporaryId" ] }, "reply_to_id": { "type": "string", - "description": "Node ID of the discussion comment to reply to, enabling threaded discussion comments. When provided, the new comment is posted as a reply to the specified top-level discussion comment. If the given node ID belongs to a nested reply, the handler automatically resolves it to the top-level parent. Only applicable for discussion comments — ignored for issue and pull request comments.", + "description": "Node ID of the discussion comment to reply to, enabling threaded discussion comments. When provided, the new comment is posted as a reply to the specified top-level discussion comment. If the given node ID belongs to a nested reply, the handler automatically resolves it to the top-level parent. Only applicable for discussion comments \u2014 ignored for issue and pull request comments.", "x-synonyms": [ "replyToId" ] @@ -493,7 +500,7 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.", + "description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.", "x-synonyms": [ "temporaryId" ] @@ -542,7 +549,7 @@ "number", "string" ], - "description": "Pull request number to add the review comment to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds the comment to the PR that triggered this workflow. Required when the workflow target is '*' (any PR) — omitting it will cause the comment to fail.", + "description": "Pull request number to add the review comment to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds the comment to the PR that triggered this workflow. Required when the workflow target is '*' (any PR) \u2014 omitting it will cause the comment to fail.", "x-synonyms": [ "pullRequestNumber" ] @@ -617,7 +624,7 @@ "number", "string" ], - "description": "Pull request number to submit the review on. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, submits the review on the PR that triggered this workflow. Required when the workflow target is '*' (any PR) — omitting it will cause the review to fail.", + "description": "Pull request number to submit the review on. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, submits the review on the PR that triggered this workflow. Required when the workflow target is '*' (any PR) \u2014 omitting it will cause the review to fail.", "x-synonyms": [ "pullRequestNumber" ] @@ -893,7 +900,7 @@ } ] }, - "description": "Labels to add (e.g., ['bug', 'priority-high']). Each entry can be either a label name string or an object with name plus optional rationale/confidence/suggest intent metadata. Labels must exist in the repository. This field is required — omitting it will cause a validation error." + "description": "Labels to add (e.g., ['bug', 'priority-high']). Each entry can be either a label name string or an object with name plus optional rationale/confidence/suggest intent metadata. Labels must exist in the repository. This field is required \u2014 omitting it will cause a validation error." }, "item_number": { "type": [ @@ -901,7 +908,7 @@ "string" ], "pattern": "^(\\d+|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Issue or PR number to add labels to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, adds labels to the issue or PR that triggered this workflow. Only works for issue or pull_request event triggers. For schedule, workflow_dispatch, or other triggers, item_number is required — omitting it will silently skip the label operation.", + "description": "Issue or PR number to add labels to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, adds labels to the issue or PR that triggered this workflow. Only works for issue or pull_request event triggers. For schedule, workflow_dispatch, or other triggers, item_number is required \u2014 omitting it will silently skip the label operation.", "x-synonyms": [ "itemNumber" ] @@ -975,7 +982,7 @@ "string" ], "pattern": "^(\\d+|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Issue or PR number to remove labels from. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, removes labels from the item that triggered this workflow.", + "description": "Issue or PR number to remove labels from. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, removes labels from the item that triggered this workflow.", "x-synonyms": [ "itemNumber" ] @@ -1023,7 +1030,7 @@ "number", "string" ], - "description": "Pull request number to add reviewers to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds reviewers to the PR that triggered this workflow. Only works for pull_request event triggers. For workflow_dispatch, schedule, or other triggers, pull_request_number is required — omitting it will silently skip the reviewer assignment.", + "description": "Pull request number to add reviewers to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds reviewers to the PR that triggered this workflow. Only works for pull_request event triggers. For workflow_dispatch, schedule, or other triggers, pull_request_number is required \u2014 omitting it will silently skip the reviewer assignment.", "x-synonyms": [ "pullRequestNumber" ] @@ -1054,7 +1061,7 @@ "number", "string" ], - "description": "Issue number to assign to the milestone. This is the numeric ID from the GitHub URL (e.g., 567 in github.com/owner/repo/issues/567). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'.", + "description": "Issue number to assign to the milestone. This is the numeric ID from the GitHub URL (e.g., 567 in github.com/owner/repo/issues/567). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'.", "x-synonyms": [ "issueNumber" ] @@ -1071,7 +1078,7 @@ }, "milestone_title": { "type": "string", - "description": "Milestone title to assign the issue to (e.g., \"v1.0\"). Used as an alternative to milestone_number — the handler looks up the milestone by title. Either milestone_number or milestone_title must be provided.", + "description": "Milestone title to assign the issue to (e.g., \"v1.0\"). Used as an alternative to milestone_number \u2014 the handler looks up the milestone by title. Either milestone_number or milestone_title must be provided.", "x-synonyms": [ "milestoneTitle" ] @@ -1099,7 +1106,7 @@ "number", "string" ], - "description": "Issue number to assign the Copilot coding agent to. This is the numeric ID from the GitHub URL (e.g., 234 in github.com/owner/repo/issues/234). Can also be a temporary_id from an issue created earlier in the same workflow run — use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. The issue should contain clear, actionable requirements. Either issue_number or pull_number must be provided, but not both.", + "description": "Issue number to assign the Copilot coding agent to. This is the numeric ID from the GitHub URL (e.g., 234 in github.com/owner/repo/issues/234). Can also be a temporary_id from an issue created earlier in the same workflow run \u2014 use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. The issue should contain clear, actionable requirements. Either issue_number or pull_number must be provided, but not both.", "x-synonyms": [ "issueNumber" ] @@ -1531,7 +1538,7 @@ }, "branch": { "type": "string", - "description": "The local branch name that contains the committed changes to push (e.g., \"feature/my-fix\"). Providing this explicitly prevents race conditions in batch workflows where the working tree may have been checked out to a different PR's branch between commit and tool-call time. When omitted, the branch is inferred from the current git HEAD — only safe for single-PR workflows." + "description": "The local branch name that contains the committed changes to push (e.g., \"feature/my-fix\"). Providing this explicitly prevents race conditions in batch workflows where the working tree may have been checked out to a different PR's branch between commit and tool-call time. When omitted, the branch is inferred from the current git HEAD \u2014 only safe for single-PR workflows." }, "pull_request_number": { "type": [ @@ -1622,7 +1629,7 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Optional temporary identifier for this artifact upload. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_chart1', '#aw_img_out'. The bare 'aw_chart1' form is also accepted. Declare this ID here if you plan to embed the artifact URL in a subsequent message body using '#aw_ID' — for example '![chart](#aw_chart1)' in a create_discussion body. The safe-outputs processor replaces '#aw_ID' references with the actual artifact download URL after upload. When skip-archive is true the URL points directly to the file and is suitable for inline images.", + "description": "Optional temporary identifier for this artifact upload. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_chart1', '#aw_img_out'. The bare 'aw_chart1' form is also accepted. Declare this ID here if you plan to embed the artifact URL in a subsequent message body using '#aw_ID' \u2014 for example '![chart](#aw_chart1)' in a create_discussion body. The safe-outputs processor replaces '#aw_ID' references with the actual artifact download URL after upload. When skip-archive is true the URL points directly to the file and is suitable for inline images.", "x-synonyms": [ "temporaryId" ] @@ -1665,7 +1672,7 @@ }, "body": { "type": "string", - "description": "Release body content in Markdown. Must be the final intended content — not a placeholder or test value. For 'replace', this becomes the entire release body. For 'append'/'prepend', this is added with a separator.", + "description": "Release body content in Markdown. Must be the final intended content \u2014 not a placeholder or test value. For 'replace', this becomes the entire release body. For 'append'/'prepend', this is added with a separator.", "minLength": 20, "maxLength": 65536 }, @@ -1884,7 +1891,7 @@ }, { "name": "set_issue_field", - "description": "Set a single GitHub issue custom field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name). Does NOT support builtin issue fields such as \"title\", \"body\", or \"state\" — use the update_issue tool for those (for open/closed state, use update_issue.status).", + "description": "Set a single GitHub issue custom field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name). Does NOT support builtin issue fields such as \"title\", \"body\", or \"state\" \u2014 use the update_issue tool for those (for open/closed state, use update_issue.status).", "inputSchema": { "type": "object", "required": [ @@ -1961,7 +1968,7 @@ "project": { "type": "string", "pattern": "^(https://github\\.com/(orgs|users)/[^/]+/projects/\\d+|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'), or a temporary project ID from a recent create_project call — use '#aw_abc1' (canonical) or bare 'aw_abc1' (also accepted). Project names or numbers alone are NOT accepted." + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'), or a temporary project ID from a recent create_project call \u2014 use '#aw_abc1' (canonical) or bare 'aw_abc1' (also accepted). Project names or numbers alone are NOT accepted." }, "operation": { "type": "string", @@ -1988,7 +1995,7 @@ "number", "string" ], - "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456), or a temporary ID from a recent create_issue call — use '#aw_abc123' (canonical); bare 'aw_abc123' is also accepted. Required when content_type is 'issue' or 'pull_request'.", + "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456), or a temporary ID from a recent create_issue call \u2014 use '#aw_abc123' (canonical); bare 'aw_abc123' is also accepted. Required when content_type is 'issue' or 'pull_request'.", "x-synonyms": [ "contentNumber" ] @@ -2019,7 +2026,7 @@ "draft_issue_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Temporary ID of an existing draft issue to update — use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted. Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates.", + "description": "Temporary ID of an existing draft issue to update \u2014 use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted. Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates.", "x-synonyms": [ "draftIssueId" ] @@ -2027,7 +2034,7 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this draft issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. Provide this when creating a new draft to enable future updates via draft_issue_id.", + "description": "Unique temporary identifier for this draft issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. Provide this when creating a new draft to enable future updates via draft_issue_id.", "x-synonyms": [ "temporaryId" ] @@ -2166,7 +2173,7 @@ }, { "name": "report_incomplete", - "description": "Signal that the task could not be completed due to an infrastructure or tool failure (e.g., MCP server crash, missing authentication, inaccessible repository). Use this when required tools or data are unavailable and the task cannot be meaningfully performed. This is distinct from noop (no action needed) — it indicates an active failure that prevented the task from running. Provide a specific reason and optional details so downstream issue aggregation can preserve complete incomplete-signal context. The workflow framework will treat this as a failure signal even when the agent exits successfully.", + "description": "Signal that the task could not be completed due to an infrastructure or tool failure (e.g., MCP server crash, missing authentication, inaccessible repository). Use this when required tools or data are unavailable and the task cannot be meaningfully performed. This is distinct from noop (no action needed) \u2014 it indicates an active failure that prevented the task from running. Provide a specific reason and optional details so downstream issue aggregation can preserve complete incomplete-signal context. The workflow framework will treat this as a failure signal even when the agent exits successfully.", "inputSchema": { "type": "object", "required": [ @@ -2215,7 +2222,7 @@ "item_url": { "type": "string", "pattern": "^(https://github\\\\.com/[^/]+/[^/]+/issues/(\\\\d+|#?aw_[A-Za-z0-9_]{3,12})|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Optional GitHub issue URL or temporary ID to add as the first item to the project. Accepts either a full URL (e.g., 'https://github.com/owner/repo/issues/123'), a URL with temporary ID (e.g., 'https://github.com/owner/repo/issues/#aw_abc1'), or a plain temporary ID — use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted.", + "description": "Optional GitHub issue URL or temporary ID to add as the first item to the project. Accepts either a full URL (e.g., 'https://github.com/owner/repo/issues/123'), a URL with temporary ID (e.g., 'https://github.com/owner/repo/issues/#aw_abc1'), or a plain temporary ID \u2014 use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted.", "x-synonyms": [ "itemUrl" ] @@ -2223,7 +2230,7 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Optional temporary identifier for this project. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. If not provided, one will be auto-generated and returned in the response. Use this same '#aw_ID' form in add_project_item to reference this project.", + "description": "Optional temporary identifier for this project. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. If not provided, one will be auto-generated and returned in the response. Use this same '#aw_ID' form in add_project_item to reference this project.", "x-synonyms": [ "temporaryId" ] @@ -2404,7 +2411,7 @@ }, { "name": "create_check_run", - "description": "Create a GitHub Check Run to report agent analysis results on a commit or pull request. Check Runs appear in the PR checks UI and on commits with a pass/fail status. Use this to surface structured analysis results as a first-class GitHub check. The check run name is configured in the workflow frontmatter and is NOT accepted as a parameter — do not pass name. When `safe-outputs.create-check-run.target` is configured, pull request targeting follows standard PR target rules. With `target: \"*\"`, include `pull_request_number` (or `pr_number`/`pr`/`pull_number`) in each call.", + "description": "Create a GitHub Check Run to report agent analysis results on a commit or pull request. Check Runs appear in the PR checks UI and on commits with a pass/fail status. Use this to surface structured analysis results as a first-class GitHub check. The check run name is configured in the workflow frontmatter and is NOT accepted as a parameter \u2014 do not pass name. When `safe-outputs.create-check-run.target` is configured, pull request targeting follows standard PR target rules. With `target: \"*\"`, include `pull_request_number` (or `pr_number`/`pr`/`pull_number`) in each call.", "inputSchema": { "type": "object", "required": [ diff --git a/pkg/workflow/mcp_setup_safe_outputs.go b/pkg/workflow/mcp_setup_safe_outputs.go index 0d0ab07015a..d607f6ab457 100644 --- a/pkg/workflow/mcp_setup_safe_outputs.go +++ b/pkg/workflow/mcp_setup_safe_outputs.go @@ -70,7 +70,13 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.Mentions != nil { mentionsBlock = buildMentionsHandlerConfig(workflowData.SafeOutputs.Mentions) } - validationConfigJSON, err := GetValidationConfigJSON(enabledTypes, mentionsBlock) + var normalizedDataSchema map[string]any + dataEnabled := false + if workflowData.SafeOutputs != nil { + normalizedDataSchema = workflowData.SafeOutputs.NormalizedDataSchema + dataEnabled = workflowData.SafeOutputs.DataEnabled + } + validationConfigJSON, err := GetValidationConfigJSONWithDataSchema(enabledTypes, mentionsBlock, dataEnabled, normalizedDataSchema) if err != nil { mcpSetupGeneratorLog.Printf("CRITICAL: Error generating validation config JSON: %v - validation will not work correctly", err) validationConfigJSON = "{}" diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index 6094759d75c..b9db74409d8 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -182,6 +182,39 @@ func TestGetValidationConfigJSONWithMentions(t *testing.T) { } } +func TestGetValidationConfigJSONWithDataSchema(t *testing.T) { + dataSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "verdict": map[string]any{"type": "string"}, + }, + "additionalProperties": false, + } + + jsonStr, err := GetValidationConfigJSONWithDataSchema([]string{"add_comment", "close_issue"}, nil, true, dataSchema) + if err != nil { + t.Fatalf("GetValidationConfigJSONWithDataSchema() error = %v", err) + } + + var parsed map[string]TypeValidationConfig + if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { + t.Fatalf("Failed to parse validation config JSON: %v", err) + } + + if parsed["add_comment"].DataSchema == nil { + t.Fatal("expected add_comment dataSchema to be present") + } + if !parsed["add_comment"].DataEnabled { + t.Fatal("expected add_comment dataEnabled to be true") + } + if parsed["close_issue"].DataSchema != nil { + t.Fatal("did not expect close_issue dataSchema to be present") + } + if parsed["close_issue"].DataEnabled { + t.Fatal("did not expect close_issue dataEnabled to be true") + } +} + func containsNewline(s string) bool { for _, r := range s { if r == '\n' { diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index 81043401f6b..181b65f17c2 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -62,6 +62,14 @@ func generateSafeOutputsConfig(data *WorkflowData) (string, error) { handlerCfg["protected_dot_folder_excludes"] = dotFolderExcludes } } + if data.SafeOutputs != nil && data.SafeOutputs.DataEnabled && isDataSchemaEnabledType(handlerName) { + handlerCfg["data_enabled"] = true + if data.SafeOutputs.NormalizedDataSchema != nil { + handlerCfg["data_schema"] = data.SafeOutputs.NormalizedDataSchema + } else if strings.TrimSpace(data.SafeOutputs.DataSchemaExpression) != "" { + handlerCfg["data_schema"] = data.SafeOutputs.DataSchemaExpression + } + } safeOutputsConfig[handlerName] = handlerCfg } } diff --git a/pkg/workflow/safe_outputs_config_generation_test.go b/pkg/workflow/safe_outputs_config_generation_test.go index 403404bcafb..99ed1ed4eb2 100644 --- a/pkg/workflow/safe_outputs_config_generation_test.go +++ b/pkg/workflow/safe_outputs_config_generation_test.go @@ -220,6 +220,43 @@ func TestPopulateDispatchWorkflowFilesNoSafeOutputs(t *testing.T) { populateDispatchWorkflowFiles(data, "/some/path") } +func TestGenerateSafeOutputsConfigAddsDataFlagsForBodyHandlers(t *testing.T) { + cfg := &SafeOutputsConfig{ + DataEnabled: true, + AddComments: &AddCommentsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + }, + } + data := &WorkflowData{SafeOutputs: cfg} + result, err := generateSafeOutputsConfig(data) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(result), &parsed)) + addComment, ok := parsed["add_comment"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, addComment["data_enabled"]) +} + +func TestGenerateSafeOutputsConfigAddsRuntimeDataSchemaExpression(t *testing.T) { + cfg := &SafeOutputsConfig{ + DataEnabled: true, + DataSchemaExpression: "${{ fromJSON(needs.schema.outputs.data_schema) }}", + AddComments: &AddCommentsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + }, + } + data := &WorkflowData{SafeOutputs: cfg} + result, err := generateSafeOutputsConfig(data) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(result), &parsed)) + addComment, ok := parsed["add_comment"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "${{ fromJSON(needs.schema.outputs.data_schema) }}", addComment["data_schema"]) +} + // TestPopulateDispatchWorkflowFilesNoWorkflows tests that the function handles empty Workflows list gracefully. func TestPopulateDispatchWorkflowFilesNoWorkflows(t *testing.T) { data := &WorkflowData{ diff --git a/pkg/workflow/safe_outputs_config_global.go b/pkg/workflow/safe_outputs_config_global.go index 8dccbc5340e..74e3f2bf902 100644 --- a/pkg/workflow/safe_outputs_config_global.go +++ b/pkg/workflow/safe_outputs_config_global.go @@ -31,6 +31,11 @@ func (c *Compiler) extractGlobalConfigFields(outputMap map[string]any, config *S } } + // Parse safe-outputs.data configuration (false, true, inline schema object, or expression). + if data, exists := outputMap["data"]; exists { + config.Data = data + } + // Parse allowed-github-references configuration if allowGitHubRefs, exists := outputMap["allowed-github-references"]; exists { if refsArray, ok := allowGitHubRefs.([]any); ok { diff --git a/pkg/workflow/safe_outputs_config_types.go b/pkg/workflow/safe_outputs_config_types.go index 412026f017d..f602fcc145c 100644 --- a/pkg/workflow/safe_outputs_config_types.go +++ b/pkg/workflow/safe_outputs_config_types.go @@ -90,6 +90,10 @@ type SafeOutputsConfig struct { Scripts map[string]*SafeScriptConfig `yaml:"scripts,omitempty"` // Custom inline handlers that run in the safe-output handler loop GitHubApp *GitHubAppConfig `yaml:"github-app,omitempty"` // GitHub App credentials for token minting URLs string `yaml:"urls,omitempty"` // URL sanitization policy: SafeOutputsURLsPolicyAllowedOnly (default) or SafeOutputsURLsPolicyAllowedOrCodeRegion + Data any `yaml:"data,omitempty"` // Structured data mode for body-based safe outputs: false/omitted (disabled), true (allow any object), object (inline schema), or GitHub Actions expression string + DataEnabled bool `yaml:"-"` // Internal flag controlling whether `data` is allowed for body-based safe outputs + NormalizedDataSchema map[string]any `yaml:"-"` // Internal normalized schema derived from inline `data` object schemas + DataSchemaExpression string `yaml:"-"` // Internal runtime GitHub Actions expression used to provide `data` schema dynamically AllowedDomains []string `yaml:"allowed-domains,omitempty"` // Allowed domains for URL redaction, unioned with network.allowed; supports ecosystem identifiers AllowGitHubReferences []string `yaml:"allowed-github-references,omitempty"` // Allowed repositories for GitHub references (e.g., ["repo", "org/repo2"]) Staged *TemplatableBool `yaml:"staged,omitempty"` // Templatable preview-only mode for all safe outputs diff --git a/pkg/workflow/safe_outputs_data_schema.go b/pkg/workflow/safe_outputs_data_schema.go new file mode 100644 index 00000000000..61e58a4d2a5 --- /dev/null +++ b/pkg/workflow/safe_outputs_data_schema.go @@ -0,0 +1,292 @@ +package workflow + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" +) + +var supportedDataSchemaTypes = map[string]struct{}{ + "object": {}, + "array": {}, + "string": {}, + "number": {}, + "integer": {}, + "boolean": {}, +} + +var dataSchemaAllowedKeys = map[string]struct{}{ + "type": {}, + "description": {}, + "properties": {}, + "required": {}, + "items": {}, + "enum": {}, + "additionalProperties": {}, + "minLength": {}, + "maxLength": {}, + "minimum": {}, + "maximum": {}, + "pattern": {}, +} + +var dataSchemaBodyTypes = []string{ + "create_issue", + "add_comment", + "create_pull_request", + "create_pull_request_review_comment", + "submit_pull_request_review", + "reply_to_pull_request_review_comment", +} + +func isDataSchemaEnabledType(typeName string) bool { + return slices.Contains(dataSchemaBodyTypes, typeName) +} + +func validateSafeOutputsDataSchema(config *SafeOutputsConfig) error { + if config == nil { + return nil + } + enabled, schema, schemaExpression, err := resolveSafeOutputsDataSchema(config) + if err != nil { + return err + } + config.DataEnabled = enabled + config.NormalizedDataSchema = schema + config.DataSchemaExpression = schemaExpression + return nil +} + +func resolveSafeOutputsDataSchema(config *SafeOutputsConfig) (bool, map[string]any, string, error) { + if config == nil { + return false, nil, "", nil + } + if config.Data == nil { + return false, nil, "", nil + } + + switch v := config.Data.(type) { + case bool: + if !v { + return false, nil, "", nil + } + return true, nil, "", nil + case map[string]any: + normalized, err := simplifyDataSchemaNode(v, "safe-outputs.data", true) + if err != nil { + return false, nil, "", err + } + if normalizedType, _ := normalized["type"].(string); normalizedType != "object" { + return false, nil, "", fmt.Errorf("safe-outputs.data must resolve to an object schema, got %q", normalizedType) + } + return true, normalized, "", nil + case string: + trimmed := strings.TrimSpace(v) + if containsExpression(trimmed) { + return true, nil, trimmed, nil + } + var parsed any + if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil { + schemaMap, ok := parsed.(map[string]any) + if !ok { + return false, nil, "", errors.New("safe-outputs.data string JSON must decode to an object schema") + } + normalized, normalizeErr := simplifyDataSchemaNode(schemaMap, "safe-outputs.data", true) + if normalizeErr != nil { + return false, nil, "", normalizeErr + } + if normalizedType, _ := normalized["type"].(string); normalizedType != "object" { + return false, nil, "", fmt.Errorf("safe-outputs.data must resolve to an object schema, got %q", normalizedType) + } + return true, normalized, "", nil + } + return false, nil, "", errors.New("safe-outputs.data string values must be a GitHub Actions expression or JSON object schema") + default: + return false, nil, "", errors.New("safe-outputs.data must be false, true, an inline schema object, or a GitHub Actions expression") + } +} + +func simplifyDataSchemaNode(raw any, path string, allowShorthand bool) (map[string]any, error) { + if typeName, ok := raw.(string); ok { + if !allowShorthand { + return nil, fmt.Errorf("%s: string shorthand is not allowed here", path) + } + if _, exists := supportedDataSchemaTypes[typeName]; !exists { + return nil, fmt.Errorf("%s: unsupported type %q", path, typeName) + } + return map[string]any{"type": typeName}, nil + } + + node, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: expected an object schema", path) + } + + explicit := hasDataSchemaKeywords(node) + if !explicit && allowShorthand { + // Shorthand object syntax: + // data: + // verdict: string + // score: number + explicit = true + node = map[string]any{ + "type": "object", + "properties": node, + } + } + + if !explicit { + return nil, fmt.Errorf("%s: expected JSON schema keywords or shorthand properties", path) + } + + for key := range node { + if _, exists := dataSchemaAllowedKeys[key]; exists { + continue + } + return nil, fmt.Errorf("%s: unsupported keyword %q", path, key) + } + + result := make(map[string]any) + typeName, _ := node["type"].(string) + if typeName == "" { + switch { + case node["properties"] != nil || node["required"] != nil || node["additionalProperties"] != nil: + typeName = "object" + case node["items"] != nil: + typeName = "array" + } + } + if typeName != "" { + if _, exists := supportedDataSchemaTypes[typeName]; !exists { + return nil, fmt.Errorf("%s.type: unsupported type %q", path, typeName) + } + result["type"] = typeName + } + + if desc, ok := node["description"]; ok { + descStr, ok := desc.(string) + if !ok { + return nil, fmt.Errorf("%s.description: must be a string", path) + } + result["description"] = descStr + } + + if enumVal, exists := node["enum"]; exists { + enumList, ok := enumVal.([]any) + if !ok || len(enumList) == 0 { + return nil, fmt.Errorf("%s.enum: must be a non-empty array", path) + } + for i, enumItem := range enumList { + switch enumItem.(type) { + case string, float64, bool, int, int64: + default: + return nil, fmt.Errorf("%s.enum[%d]: must be a scalar value", path, i) + } + } + result["enum"] = enumList + } + + switch typeName { + case "object": + propertiesVal, hasProperties := node["properties"] + if !hasProperties { + return nil, fmt.Errorf("%s.properties: is required for object schemas", path) + } + propertiesMap, ok := propertiesVal.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s.properties: must be an object", path) + } + normalizedProperties := make(map[string]any, len(propertiesMap)) + for key, propertySchema := range propertiesMap { + normalizedProperty, err := simplifyDataSchemaNode(propertySchema, fmt.Sprintf("%s.properties.%s", path, key), true) + if err != nil { + return nil, err + } + normalizedProperties[key] = normalizedProperty + } + result["properties"] = normalizedProperties + requiredSet := make(map[string]struct{}, len(normalizedProperties)) + if requiredVal, exists := node["required"]; exists { + requiredItems, ok := requiredVal.([]any) + if !ok { + return nil, fmt.Errorf("%s.required: must be an array of strings", path) + } + for i, requiredItem := range requiredItems { + requiredName, ok := requiredItem.(string) + if !ok || strings.TrimSpace(requiredName) == "" { + return nil, fmt.Errorf("%s.required[%d]: must be a non-empty string", path, i) + } + if _, exists := normalizedProperties[requiredName]; !exists { + return nil, fmt.Errorf("%s.required[%d]: unknown property %q", path, i, requiredName) + } + requiredSet[requiredName] = struct{}{} + } + } + // OpenAI Codex structured outputs compatibility: + // - require all object properties at every object level + // - represent optionality explicitly in schema types instead of omitting from required + // To keep output deterministic, store names in lexical order. + requiredNames := make([]string, 0, len(normalizedProperties)) + for propertyName := range normalizedProperties { + requiredSet[propertyName] = struct{}{} + } + for requiredName := range requiredSet { + requiredNames = append(requiredNames, requiredName) + } + sort.Strings(requiredNames) + result["required"] = requiredNames + if additionalProps, exists := node["additionalProperties"]; exists { + additionalPropsBool, ok := additionalProps.(bool) + if !ok { + return nil, fmt.Errorf("%s.additionalProperties: must be boolean", path) + } + if additionalPropsBool { + return nil, fmt.Errorf("%s.additionalProperties: must be false for OpenAI Codex structured outputs compatibility", path) + } + result["additionalProperties"] = false + } else { + result["additionalProperties"] = false + } + case "array": + items, exists := node["items"] + if !exists { + return nil, fmt.Errorf("%s.items: is required for array schemas", path) + } + normalizedItems, err := simplifyDataSchemaNode(items, path+".items", true) + if err != nil { + return nil, err + } + result["items"] = normalizedItems + case "string": + if minLen, exists := node["minLength"]; exists { + result["minLength"] = minLen + } + if maxLen, exists := node["maxLength"]; exists { + result["maxLength"] = maxLen + } + if pattern, exists := node["pattern"]; exists { + result["pattern"] = pattern + } + case "number", "integer": + if min, exists := node["minimum"]; exists { + result["minimum"] = min + } + if max, exists := node["maximum"]; exists { + result["maximum"] = max + } + } + + return result, nil +} + +func hasDataSchemaKeywords(node map[string]any) bool { + for key := range node { + if _, exists := dataSchemaAllowedKeys[key]; exists { + return true + } + } + return false +} diff --git a/pkg/workflow/safe_outputs_data_schema_test.go b/pkg/workflow/safe_outputs_data_schema_test.go new file mode 100644 index 00000000000..d7d1e1cc21f --- /dev/null +++ b/pkg/workflow/safe_outputs_data_schema_test.go @@ -0,0 +1,132 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateSafeOutputsDataSchemaInlineShorthand(t *testing.T) { + cfg := &SafeOutputsConfig{ + Data: map[string]any{ + "verdict": "string", + "criteria_passed": "number", + }, + } + + err := validateSafeOutputsDataSchema(cfg) + require.NoError(t, err) + assert.True(t, cfg.DataEnabled) + require.NotNil(t, cfg.NormalizedDataSchema) + assert.Equal(t, "object", cfg.NormalizedDataSchema["type"]) + assert.Equal(t, false, cfg.NormalizedDataSchema["additionalProperties"]) + assert.Equal(t, []string{"criteria_passed", "verdict"}, cfg.NormalizedDataSchema["required"]) + properties, ok := cfg.NormalizedDataSchema["properties"].(map[string]any) + require.True(t, ok) + verdict, ok := properties["verdict"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "string", verdict["type"]) +} + +func TestValidateSafeOutputsDataSchemaRejectsInvalidDataType(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + data any + }{ + {name: "string path", data: "data-schema.json"}, + {name: "numeric", data: 42}, + {name: "array", data: []any{"string"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cfg := &SafeOutputsConfig{Data: tc.data} + + err := validateSafeOutputsDataSchema(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "safe-outputs.data") + }) + } +} + +func TestValidateSafeOutputsDataSchemaRejectsUnsupportedKeyword(t *testing.T) { + cfg := &SafeOutputsConfig{ + Data: map[string]any{ + "type": "object", + "properties": map[string]any{ + "verdict": map[string]any{ + "type": "string", + "$ref": "#/definitions/other", + }, + }, + }, + } + + err := validateSafeOutputsDataSchema(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported keyword") +} + +func TestValidateSafeOutputsDataSchemaRejectsAdditionalPropertiesTrue(t *testing.T) { + cfg := &SafeOutputsConfig{ + Data: map[string]any{ + "type": "object", + "properties": map[string]any{ + "verdict": "string", + }, + "additionalProperties": true, + }, + } + + err := validateSafeOutputsDataSchema(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be false for OpenAI Codex structured outputs compatibility") +} + +func TestValidateSafeOutputsDataSchemaDisabledByDefault(t *testing.T) { + cfg := &SafeOutputsConfig{} + err := validateSafeOutputsDataSchema(cfg) + require.NoError(t, err) + assert.False(t, cfg.DataEnabled) + assert.Nil(t, cfg.NormalizedDataSchema) +} + +func TestValidateSafeOutputsDataSchemaBooleanTrueAllowsAnyObject(t *testing.T) { + cfg := &SafeOutputsConfig{Data: true} + err := validateSafeOutputsDataSchema(cfg) + require.NoError(t, err) + assert.True(t, cfg.DataEnabled) + assert.Nil(t, cfg.NormalizedDataSchema) +} + +func TestValidateSafeOutputsDataSchemaAllowsExpression(t *testing.T) { + cfg := &SafeOutputsConfig{Data: "${{ fromJSON(inputs.safe_outputs_data_schema) }}"} + err := validateSafeOutputsDataSchema(cfg) + require.NoError(t, err) + assert.True(t, cfg.DataEnabled) + assert.Nil(t, cfg.NormalizedDataSchema) + assert.Equal(t, "${{ fromJSON(inputs.safe_outputs_data_schema) }}", cfg.DataSchemaExpression) +} + +func TestValidateSafeOutputsDataSchemaAllowsJSONStringSchema(t *testing.T) { + cfg := &SafeOutputsConfig{ + Data: `{"type":"object","properties":{"verdict":{"type":"string"}},"required":["verdict"],"additionalProperties":false}`, + } + err := validateSafeOutputsDataSchema(cfg) + require.NoError(t, err) + assert.True(t, cfg.DataEnabled) + require.NotNil(t, cfg.NormalizedDataSchema) + assert.Equal(t, "object", cfg.NormalizedDataSchema["type"]) +} + +func TestValidateSafeOutputsDataSchemaRejectsInvalidStringSyntax(t *testing.T) { + cfg := &SafeOutputsConfig{Data: "not json and not expression"} + err := validateSafeOutputsDataSchema(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a GitHub Actions expression or JSON object schema") +} diff --git a/pkg/workflow/safe_outputs_tools_generation.go b/pkg/workflow/safe_outputs_tools_generation.go index dc0a7a84462..e40dd8a1bd1 100644 --- a/pkg/workflow/safe_outputs_tools_generation.go +++ b/pkg/workflow/safe_outputs_tools_generation.go @@ -295,49 +295,64 @@ var closeIssueStateReasonValues = []string{"completed", "not_planned", "duplicat // - Scalar config (state-reason: "..."): no injection (fixed reason, agent cannot choose). func computePropertyInjections(safeOutputs *SafeOutputsConfig) map[string]map[string]any { injections := make(map[string]map[string]any) - if safeOutputs == nil || safeOutputs.CloseIssues == nil { - return injections - } - c := safeOutputs.CloseIssues - // Scalar config: agent cannot change state_reason; do not expose the field. - if c.StateReason != "" { + if safeOutputs == nil { return injections } - // List or omitted: expose state_reason with the permitted enum. - enumValues := c.AllowedStateReason - if len(enumValues) == 0 { - enumValues = closeIssueStateReasonValues - } else { - // Validate each configured value against the supported API values so that - // invalid strings (e.g. "done", "wontfix") are caught at compile time rather - // than producing a GitHub API 422 at runtime. - supported := make(map[string]struct{}, len(closeIssueStateReasonValues)) - for _, v := range closeIssueStateReasonValues { - supported[v] = struct{}{} - } - valid := make([]string, 0, len(enumValues)) - for _, v := range enumValues { - if _, ok := supported[v]; ok { - valid = append(valid, v) + if safeOutputs.CloseIssues != nil { + c := safeOutputs.CloseIssues + // Scalar config: agent cannot change state_reason; do not expose the field. + if c.StateReason == "" { + // List or omitted: expose state_reason with the permitted enum. + enumValues := c.AllowedStateReason + if len(enumValues) == 0 { + enumValues = closeIssueStateReasonValues } else { - safeOutputsConfigLog.Printf("Warning: allowed-state-reason value %q is not a supported GitHub API value; valid values: %v", v, closeIssueStateReasonValues) + // Validate each configured value against the supported API values so that + // invalid strings (e.g. "done", "wontfix") are caught at compile time rather + // than producing a GitHub API 422 at runtime. + supported := make(map[string]struct{}, len(closeIssueStateReasonValues)) + for _, v := range closeIssueStateReasonValues { + supported[v] = struct{}{} + } + valid := make([]string, 0, len(enumValues)) + for _, v := range enumValues { + if _, ok := supported[v]; ok { + valid = append(valid, v) + } else { + safeOutputsConfigLog.Printf("Warning: allowed-state-reason value %q is not a supported GitHub API value; valid values: %v", v, closeIssueStateReasonValues) + } + } + if len(valid) == 0 { + // All values were invalid; fall back to the full set so that compilation + // succeeds, relying on schema validation to have already warned the author. + safeOutputsConfigLog.Printf("Warning: all allowed-state-reason values were invalid; falling back to full supported set") + valid = closeIssueStateReasonValues + } + enumValues = valid + } + injections["close_issue"] = map[string]any{ + "state_reason": map[string]any{ + "type": "string", + "enum": enumValues, + "description": "Optional closing state reason. Omit to use the configured default. Select 'duplicate' together with 'duplicate_of' to mark a native duplicate relationship.", + }, } } - if len(valid) == 0 { - // All values were invalid; fall back to the full set so that compilation - // succeeds, relying on schema validation to have already warned the author. - safeOutputsConfigLog.Printf("Warning: all allowed-state-reason values were invalid; falling back to full supported set") - valid = closeIssueStateReasonValues - } - enumValues = valid } - injections["close_issue"] = map[string]any{ - "state_reason": map[string]any{ - "type": "string", - "enum": enumValues, - "description": "Optional closing state reason. Omit to use the configured default. Select 'duplicate' together with 'duplicate_of' to mark a native duplicate relationship.", - }, + + if safeOutputs.DataEnabled { + dataProperty := map[string]any{"$ref": "#/0/inputSchema/$defs/structured_data"} + if safeOutputs.NormalizedDataSchema != nil { + dataProperty = safeOutputs.NormalizedDataSchema + } + for _, typeName := range dataSchemaBodyTypes { + if injections[typeName] == nil { + injections[typeName] = make(map[string]any) + } + injections[typeName]["data"] = dataProperty + } } + return injections } diff --git a/pkg/workflow/safe_outputs_tools_generation_test.go b/pkg/workflow/safe_outputs_tools_generation_test.go index 88f02868cb6..38d730ebc02 100644 --- a/pkg/workflow/safe_outputs_tools_generation_test.go +++ b/pkg/workflow/safe_outputs_tools_generation_test.go @@ -590,3 +590,34 @@ func TestComputePropertyInjectionsAllInvalidFallsBackToFullSet(t *testing.T) { require.True(t, ok) assert.Equal(t, closeIssueStateReasonValues, prop["enum"]) } + +func TestComputePropertyInjectionsAddsDataSchemaForBodyTypes(t *testing.T) { + injections := computePropertyInjections(&SafeOutputsConfig{ + DataEnabled: true, + NormalizedDataSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "verdict": map[string]any{"type": "string"}, + }, + "additionalProperties": false, + }, + }) + + for _, typeName := range dataSchemaBodyTypes { + require.Contains(t, injections, typeName) + prop, ok := injections[typeName]["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "object", prop["type"]) + } +} + +func TestComputePropertyInjectionsAddsGenericDataForBodyTypes(t *testing.T) { + injections := computePropertyInjections(&SafeOutputsConfig{DataEnabled: true}) + + for _, typeName := range dataSchemaBodyTypes { + require.Contains(t, injections, typeName) + prop, ok := injections[typeName]["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "#/0/inputSchema/$defs/structured_data", prop["$ref"]) + } +} diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 76662a69d5e..59d096b3092 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -42,6 +42,8 @@ type TypeValidationConfig struct { DefaultMax int `json:"defaultMax"` Fields map[string]FieldValidation `json:"fields"` CustomValidation string `json:"customValidation,omitempty"` + DataEnabled bool `json:"dataEnabled,omitempty"` + DataSchema map[string]any `json:"dataSchema,omitempty"` } // Constants for validation @@ -495,10 +497,16 @@ var validationConfigJSONCache sync.Map // key: string → value: string // during the initial sanitization pass (mirroring what the publish-side handlers // receive via GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG). func GetValidationConfigJSON(enabledTypes []string, mentions map[string]any) (string, error) { + return GetValidationConfigJSONWithDataSchema(enabledTypes, mentions, false, nil) +} + +// GetValidationConfigJSONWithDataSchema behaves like GetValidationConfigJSON and additionally +// injects a normalized data schema into body-bearing safe-output types. +func GetValidationConfigJSONWithDataSchema(enabledTypes []string, mentions map[string]any, dataEnabled bool, dataSchema map[string]any) (string, error) { safeOutputValidationLog.Printf("Getting validation config JSON for %d types (mentions=%t)", len(enabledTypes), len(mentions) > 0) // Cache only the schema-only path; mentions are workflow-specific and cheap to remarshal. - if len(mentions) == 0 { + if len(mentions) == 0 && !dataEnabled && dataSchema == nil { cacheKey := buildValidationConfigCacheKey(enabledTypes) if cached, ok := validationConfigJSONCache.Load(cacheKey); ok { safeOutputValidationLog.Print("Returning cached validation config JSON") @@ -523,6 +531,18 @@ func GetValidationConfigJSON(enabledTypes []string, mentions map[string]any) (st } else { safeOutputValidationLog.Print("Returning all validation configs") } + if dataEnabled || dataSchema != nil { + withDataSchema := make(map[string]TypeValidationConfig, len(configToMarshal)) + for typeName, typeConfig := range configToMarshal { + copied := typeConfig + if isDataSchemaEnabledType(typeName) { + copied.DataEnabled = dataEnabled + copied.DataSchema = dataSchema + } + withDataSchema[typeName] = copied + } + configToMarshal = withDataSchema + } var data []byte var err error @@ -542,7 +562,7 @@ func GetValidationConfigJSON(enabledTypes []string, mentions map[string]any) (st } result := string(data) safeOutputValidationLog.Printf("Generated validation config JSON with %d bytes", len(result)) - if len(mentions) == 0 { + if len(mentions) == 0 && !dataEnabled && dataSchema == nil { validationConfigJSONCache.Store(buildValidationConfigCacheKey(enabledTypes), result) } return result, nil diff --git a/pkg/workflow/samples_validation.go b/pkg/workflow/samples_validation.go index 5313262da51..d39761de993 100644 --- a/pkg/workflow/samples_validation.go +++ b/pkg/workflow/samples_validation.go @@ -77,22 +77,31 @@ func getCompiledToolSchemas() (map[string]toolSchemaEntry, error) { compiledToolSchemasErr = fmt.Errorf("failed to parse safe_outputs_tools.json for samples validation: %w", err) return } + + sharedDefs := extractSharedInputSchemaDefs(tools) out := make(map[string]toolSchemaEntry, len(tools)) for _, t := range tools { if len(t.InputSchema) == 0 { continue } - schemaURL := fmt.Sprintf("inmem://safe-outputs-tools/%s.json", t.Name) - schema, err := compileSchema(string(t.InputSchema), schemaURL) - if err != nil { - compiledToolSchemasErr = fmt.Errorf("failed to compile inputSchema for tool %q: %w", t.Name, err) - return - } var rawMap map[string]any if err := json.Unmarshal(t.InputSchema, &rawMap); err != nil { compiledToolSchemasErr = fmt.Errorf("failed to parse inputSchema for tool %q: %w", t.Name, err) return } + normalizeInputSchemaRefs(rawMap, sharedDefs) + normalizedSchemaBytes, err := json.Marshal(rawMap) + if err != nil { + compiledToolSchemasErr = fmt.Errorf("failed to normalize inputSchema for tool %q: %w", t.Name, err) + return + } + + schemaURL := fmt.Sprintf("inmem://safe-outputs-tools/%s.json", t.Name) + schema, err := compileSchema(string(normalizedSchemaBytes), schemaURL) + if err != nil { + compiledToolSchemasErr = fmt.Errorf("failed to compile inputSchema for tool %q: %w", t.Name, err) + return + } out[t.Name] = toolSchemaEntry{raw: rawMap, compiled: schema} } samplesValidationLog.Printf("Compiled %d safe-outputs tool schemas for sample validation", len(out)) @@ -101,6 +110,70 @@ func getCompiledToolSchemas() (map[string]toolSchemaEntry, error) { return compiledToolSchemas, compiledToolSchemasErr } +func extractSharedInputSchemaDefs(tools []struct { + Name string `json:"name"` + InputSchema json.RawMessage `json:"inputSchema"` +}) map[string]any { + if len(tools) == 0 || len(tools[0].InputSchema) == 0 { + return nil + } + + var firstSchema map[string]any + if err := json.Unmarshal(tools[0].InputSchema, &firstSchema); err != nil { + return nil + } + defs, ok := firstSchema["$defs"].(map[string]any) + if !ok || len(defs) == 0 { + return nil + } + return defs +} + +func normalizeInputSchemaRefs(schema map[string]any, sharedDefs map[string]any) { + if schema == nil { + return + } + + rewroteRefs := rewriteSchemaRefPaths(schema) + if !rewroteRefs || len(sharedDefs) == 0 { + return + } + + localDefs, _ := schema["$defs"].(map[string]any) + if localDefs == nil { + localDefs = map[string]any{} + schema["$defs"] = localDefs + } + for name, def := range sharedDefs { + if _, exists := localDefs[name]; !exists { + localDefs[name] = def + } + } +} + +func rewriteSchemaRefPaths(node any) bool { + rewrote := false + switch typed := node.(type) { + case map[string]any: + if ref, ok := typed["$ref"].(string); ok && strings.HasPrefix(ref, "#/0/inputSchema/$defs/") { + typed["$ref"] = "#/$defs/" + strings.TrimPrefix(ref, "#/0/inputSchema/$defs/") + rewrote = true + } + for _, value := range typed { + if rewriteSchemaRefPaths(value) { + rewrote = true + } + } + case []any: + for _, item := range typed { + if rewriteSchemaRefPaths(item) { + rewrote = true + } + } + } + return rewrote +} + func getSortedSafeOutputFieldNames() []string { sortedSafeOutputFieldNamesOnce.Do(func() { sortedSafeOutputFieldNames = sliceutil.SortedKeys(safeOutputFieldMapping)