From 49bf893cc29ba3ccd522548682cd7f15eec8b375 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Sep 2026 01:40:23 +0000
Subject: [PATCH 1/7] Add native Linear safe outputs
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../setup/js/generate_safe_outputs_tools.cjs | 14 +++
actions/setup/js/linear_add_comment.cjs | 50 ++++++++
actions/setup/js/linear_create_issue.cjs | 66 ++++++++++
actions/setup/js/linear_graphql.cjs | 57 +++++++++
actions/setup/js/linear_safe_outputs.test.cjs | 101 +++++++++++++++
actions/setup/js/linear_update_issue.cjs | 75 ++++++++++++
.../setup/js/safe_output_handler_manager.cjs | 6 +
actions/setup/js/safe_outputs_tools.json | 66 ++++++++++
.../content/docs/reference/safe-outputs.md | 28 +++++
.../docs/specs/safe-outputs-specification.md | 76 +++++++++++-
pkg/parser/schema_linear_safe_outputs_test.go | 88 ++++++++++++++
pkg/parser/schemas/main_workflow_schema.json | 115 ++++++++++++++++++
pkg/workflow/compiler_safe_outputs_job.go | 4 +
pkg/workflow/js/safe_outputs_tools.json | 66 ++++++++++
pkg/workflow/linear_safe_outputs.go | 92 ++++++++++++++
pkg/workflow/linear_safe_outputs_test.go | 97 +++++++++++++++
pkg/workflow/safe_output_handlers.go | 18 +++
.../safe_outputs_config_extraction.go | 6 +
pkg/workflow/safe_outputs_config_global.go | 8 ++
pkg/workflow/safe_outputs_config_types.go | 4 +
pkg/workflow/safe_outputs_handler_registry.go | 1 +
.../safe_outputs_handler_registry_linear.go | 44 +++++++
.../safe_outputs_tools_computation.go | 11 ++
23 files changed, 1091 insertions(+), 2 deletions(-)
create mode 100644 actions/setup/js/linear_add_comment.cjs
create mode 100644 actions/setup/js/linear_create_issue.cjs
create mode 100644 actions/setup/js/linear_graphql.cjs
create mode 100644 actions/setup/js/linear_safe_outputs.test.cjs
create mode 100644 actions/setup/js/linear_update_issue.cjs
create mode 100644 pkg/parser/schema_linear_safe_outputs_test.go
create mode 100644 pkg/workflow/linear_safe_outputs.go
create mode 100644 pkg/workflow/linear_safe_outputs_test.go
create mode 100644 pkg/workflow/safe_outputs_handler_registry_linear.go
diff --git a/actions/setup/js/generate_safe_outputs_tools.cjs b/actions/setup/js/generate_safe_outputs_tools.cjs
index 1b61c55a0bc..c6119d3e76f 100644
--- a/actions/setup/js/generate_safe_outputs_tools.cjs
+++ b/actions/setup/js/generate_safe_outputs_tools.cjs
@@ -383,6 +383,20 @@ async function main() {
enhancedTool.description = updateAddCommentDescription(enhancedTool.description, config.add_comment);
}
+ if (tool.name === "linear_update_issue") {
+ const linearUpdateConfig = config.linear_update_issue;
+ const properties = enhancedTool.inputSchema?.properties;
+ if (properties && linearUpdateConfig && typeof linearUpdateConfig === "object") {
+ if (linearUpdateConfig.allow_title !== true) {
+ delete properties.title;
+ }
+ if (linearUpdateConfig.allow_body !== true) {
+ delete properties.body;
+ }
+ enhancedTool.inputSchema.anyOf = Object.keys(properties).map(field => ({ required: [field] }));
+ }
+ }
+
// Add repo parameter to inputSchema if configured
const repoParam = toolsMeta.repo_params?.[tool.name];
if (repoParam) {
diff --git a/actions/setup/js/linear_add_comment.cjs b/actions/setup/js/linear_add_comment.cjs
new file mode 100644
index 00000000000..e7f68be7adb
--- /dev/null
+++ b/actions/setup/js/linear_add_comment.cjs
@@ -0,0 +1,50 @@
+// @ts-check
+///
+
+const { sanitizeContent } = require("./sanitize_content.cjs");
+const { LINEAR_ISSUE_PATTERN, linearGraphQL } = require("./linear_graphql.cjs");
+const { isStagedMode } = require("./safe_output_helpers.cjs");
+const { logStagedPreviewInfo } = require("./staged_preview.cjs");
+const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");
+
+const LINEAR_COMMENT_CREATE = `mutation LinearAddComment($input: CommentCreateInput!) {
+ commentCreate(input: $input) {
+ success
+ comment {
+ id
+ body
+ }
+ }
+}`;
+async function main(config = {}) {
+ const target = config.target;
+ if (typeof target !== "string" || target.length > 100 || !LINEAR_ISSUE_PATTERN.test(target)) {
+ throw new Error(`${ERR_CONFIG}: linear_add_comment requires a valid configured target`);
+ }
+
+ return async function handleLinearAddComment(item) {
+ if (typeof item?.body !== "string" || !item.body.trim()) {
+ throw new Error(`${ERR_VALIDATION}: linear_add_comment body is required`);
+ }
+ if (item.body.length > 65536) {
+ throw new Error(`${ERR_VALIDATION}: linear_add_comment body exceeds 65536 characters`);
+ }
+ const body = sanitizeContent(item.body);
+
+ if (isStagedMode(config)) {
+ logStagedPreviewInfo(`Would add a comment to Linear issue ${target}`);
+ return { success: true, staged: true, target };
+ }
+
+ const data = await linearGraphQL(LINEAR_COMMENT_CREATE, {
+ input: { issueId: target, body },
+ });
+ const payload = data?.commentCreate;
+ if (payload?.success !== true || !payload.comment) {
+ throw new Error(`${ERR_API}: Linear commentCreate did not return a successful comment`);
+ }
+ return { success: true, id: payload.comment.id, target };
+ };
+}
+
+module.exports = { LINEAR_COMMENT_CREATE, main };
diff --git a/actions/setup/js/linear_create_issue.cjs b/actions/setup/js/linear_create_issue.cjs
new file mode 100644
index 00000000000..59e9502408b
--- /dev/null
+++ b/actions/setup/js/linear_create_issue.cjs
@@ -0,0 +1,66 @@
+// @ts-check
+///
+
+const { sanitizeTitle } = require("./sanitize_title.cjs");
+const { sanitizeContent } = require("./sanitize_content.cjs");
+const { linearGraphQL } = require("./linear_graphql.cjs");
+const { isStagedMode } = require("./safe_output_helpers.cjs");
+const { logStagedPreviewInfo } = require("./staged_preview.cjs");
+const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");
+
+const LINEAR_CREATE_ISSUE = `mutation LinearCreateIssue($input: IssueCreateInput!) {
+ issueCreate(input: $input) {
+ success
+ issue {
+ id
+ identifier
+ title
+ }
+ }
+}`;
+
+async function main(config = {}) {
+ const teamId = config.team_id;
+ if (typeof teamId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(teamId)) {
+ throw new Error(`${ERR_CONFIG}: linear_create_issue requires a valid configured team ID`);
+ }
+
+ return async function handleLinearCreateIssue(item) {
+ if (typeof item?.title !== "string" || !item.title.trim()) {
+ throw new Error(`${ERR_VALIDATION}: linear_create_issue title is required`);
+ }
+ if (typeof item?.body !== "string") {
+ throw new Error(`${ERR_VALIDATION}: linear_create_issue body is required`);
+ }
+ if (item.title.length > 256 || item.body.length > 65536) {
+ throw new Error(`${ERR_VALIDATION}: linear_create_issue content exceeds the configured field limits`);
+ }
+
+ const title = sanitizeTitle(item.title, "", 256);
+ const description = sanitizeContent(item.body);
+ if (!title) {
+ throw new Error(`${ERR_VALIDATION}: linear_create_issue title is empty after sanitization`);
+ }
+
+ if (isStagedMode(config)) {
+ logStagedPreviewInfo(`Would create Linear issue "${title}"`);
+ return { success: true, staged: true, title };
+ }
+
+ const data = await linearGraphQL(LINEAR_CREATE_ISSUE, {
+ input: { teamId, title, description },
+ });
+ const payload = data?.issueCreate;
+ if (payload?.success !== true || !payload.issue) {
+ throw new Error(`${ERR_API}: Linear issueCreate did not return a successful issue`);
+ }
+ return {
+ success: true,
+ id: payload.issue.id,
+ identifier: payload.issue.identifier,
+ title: payload.issue.title,
+ };
+ };
+}
+
+module.exports = { LINEAR_CREATE_ISSUE, main };
diff --git a/actions/setup/js/linear_graphql.cjs b/actions/setup/js/linear_graphql.cjs
new file mode 100644
index 00000000000..4c1db6ac64b
--- /dev/null
+++ b/actions/setup/js/linear_graphql.cjs
@@ -0,0 +1,57 @@
+// @ts-check
+
+const { ERR_API, ERR_CONFIG, ERR_PARSE } = require("./error_codes.cjs");
+const { getErrorMessage } = require("./error_helpers.cjs");
+
+const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
+const LINEAR_ISSUE_PATTERN = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[A-Z][A-Z0-9]{0,15}-[1-9][0-9]*)$/i;
+
+function redactToken(value, token) {
+ const text = String(value || "");
+ return token ? text.split(token).join("***") : text;
+}
+
+async function linearGraphQL(query, variables, token = process.env.GH_AW_LINEAR_TOKEN) {
+ if (!token) {
+ throw new Error(`${ERR_CONFIG}: Linear API token is not configured`);
+ }
+
+ let response;
+ try {
+ response = await fetch(LINEAR_GRAPHQL_ENDPOINT, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: token,
+ },
+ body: JSON.stringify({ query, variables }),
+ signal: AbortSignal.timeout(30_000),
+ });
+ } catch (error) {
+ throw new Error(`${ERR_API}: Linear request failed: ${redactToken(getErrorMessage(error), token)}`, { cause: error });
+ }
+
+ if (!response.ok) {
+ const detail = response.status === 429 ? "rate limit exceeded" : `HTTP ${response.status}`;
+ throw new Error(`${ERR_API}: Linear request failed: ${detail}`);
+ }
+
+ let payload;
+ try {
+ payload = await response.json();
+ } catch (error) {
+ throw new Error(`${ERR_PARSE}: Linear returned a malformed JSON response`, { cause: error });
+ }
+
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ throw new Error(`${ERR_PARSE}: Linear returned an invalid GraphQL response`);
+ }
+ if (Array.isArray(payload.errors) && payload.errors.length > 0) {
+ const message = redactToken(payload.errors[0]?.message || "unknown GraphQL error", token).slice(0, 500);
+ throw new Error(`${ERR_API}: Linear GraphQL operation failed: ${message}`);
+ }
+
+ return payload.data;
+}
+
+module.exports = { LINEAR_GRAPHQL_ENDPOINT, LINEAR_ISSUE_PATTERN, linearGraphQL };
diff --git a/actions/setup/js/linear_safe_outputs.test.cjs b/actions/setup/js/linear_safe_outputs.test.cjs
new file mode 100644
index 00000000000..85caa8cede8
--- /dev/null
+++ b/actions/setup/js/linear_safe_outputs.test.cjs
@@ -0,0 +1,101 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createRequire } from "module";
+
+const require = createRequire(import.meta.url);
+const { LINEAR_GRAPHQL_ENDPOINT, linearGraphQL } = require("./linear_graphql.cjs");
+const { LINEAR_CREATE_ISSUE, main: createIssue } = require("./linear_create_issue.cjs");
+const { LINEAR_COMMENT_CREATE, main: addComment } = require("./linear_add_comment.cjs");
+const { LINEAR_UPDATE_ISSUE, main: updateIssue } = require("./linear_update_issue.cjs");
+
+global.core = { info: vi.fn(), warning: vi.fn(), debug: vi.fn() };
+
+function response(payload, status = 200) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: vi.fn().mockResolvedValue(payload),
+ };
+}
+
+describe("Linear safe outputs", () => {
+ beforeEach(() => {
+ process.env.GH_AW_LINEAR_TOKEN = "linear-secret";
+ global.fetch = vi.fn();
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ delete process.env.GH_AW_LINEAR_TOKEN;
+ delete global.fetch;
+ });
+
+ it("posts fixed GraphQL documents with variables and raw API-key authorization", async () => {
+ fetch.mockResolvedValue(response({ data: { issueCreate: { success: true, issue: { id: "id", identifier: "ENG-1", title: "Safe title" } } } }));
+ const handler = await createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d" });
+ await handler({ title: "Safe title", body: "Hello @user" });
+
+ expect(fetch).toHaveBeenCalledWith(
+ LINEAR_GRAPHQL_ENDPOINT,
+ expect.objectContaining({
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: "linear-secret" },
+ })
+ );
+ const request = JSON.parse(fetch.mock.calls[0][1].body);
+ expect(request.query).toBe(LINEAR_CREATE_ISSUE);
+ expect(request.query).not.toContain("Safe title");
+ expect(request.variables.input).toEqual({
+ teamId: "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
+ title: "Safe title",
+ description: "Hello `@user`",
+ });
+ });
+
+ it("creates a comment against only the configured target", async () => {
+ fetch.mockResolvedValue(response({ data: { commentCreate: { success: true, comment: { id: "comment-id", body: "body" } } } }));
+ const handler = await addComment({ target: "ENG-123" });
+ await handler({ body: "Comment @team" });
+
+ const request = JSON.parse(fetch.mock.calls[0][1].body);
+ expect(request.query).toBe(LINEAR_COMMENT_CREATE);
+ expect(request.variables).toEqual({ input: { issueId: "ENG-123", body: "Comment `@team`" } });
+ });
+
+ it("updates only enabled fields against the configured target", async () => {
+ fetch.mockResolvedValue(response({ data: { issueUpdate: { success: true, issue: { id: "id", identifier: "ENG-123", title: "New" } } } }));
+ const handler = await updateIssue({ target: "ENG-123", allow_title: true });
+ await expect(handler({ body: "not enabled" })).rejects.toThrow("body updates are not enabled");
+ await handler({ title: "New @owner" });
+
+ const request = JSON.parse(fetch.mock.calls[0][1].body);
+ expect(request.query).toBe(LINEAR_UPDATE_ISSUE);
+ expect(request.variables).toEqual({ id: "ENG-123", input: { title: "New `@owner`" } });
+ });
+
+ it("does not perform network requests in staged mode", async () => {
+ const handler = await addComment({ target: "ENG-123", staged: true });
+ await expect(handler({ body: "Preview" })).resolves.toMatchObject({ success: true, staged: true });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("rejects HTTP, malformed JSON, GraphQL, and unsuccessful mutation responses", async () => {
+ fetch.mockResolvedValueOnce(response({}, 429));
+ await expect(linearGraphQL("query Fixed { viewer { id } }", {})).rejects.toThrow("rate limit exceeded");
+
+ fetch.mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockRejectedValue(new Error("bad")) });
+ await expect(linearGraphQL("query Fixed { viewer { id } }", {})).rejects.toThrow("malformed JSON");
+
+ fetch.mockResolvedValueOnce(response({ errors: [{ message: "denied linear-secret" }] }));
+ await expect(linearGraphQL("query Fixed { viewer { id } }", {})).rejects.not.toThrow("linear-secret");
+
+ fetch.mockResolvedValueOnce(response({ data: { issueCreate: { success: false, issue: null } } }));
+ const handler = await createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d" });
+ await expect(handler({ title: "Title", body: "Body" })).rejects.toThrow("did not return a successful issue");
+ });
+
+ it("rejects oversized content instead of truncating it", async () => {
+ const handler = await addComment({ target: "ENG-123" });
+ await expect(handler({ body: "x".repeat(65537) })).rejects.toThrow("exceeds 65536 characters");
+ expect(fetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/actions/setup/js/linear_update_issue.cjs b/actions/setup/js/linear_update_issue.cjs
new file mode 100644
index 00000000000..7f2efe54489
--- /dev/null
+++ b/actions/setup/js/linear_update_issue.cjs
@@ -0,0 +1,75 @@
+// @ts-check
+///
+
+const { sanitizeTitle } = require("./sanitize_title.cjs");
+const { sanitizeContent } = require("./sanitize_content.cjs");
+const { LINEAR_ISSUE_PATTERN, linearGraphQL } = require("./linear_graphql.cjs");
+const { isStagedMode } = require("./safe_output_helpers.cjs");
+const { logStagedPreviewInfo } = require("./staged_preview.cjs");
+const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");
+
+const LINEAR_UPDATE_ISSUE = `mutation LinearUpdateIssue($id: String!, $input: IssueUpdateInput!) {
+ issueUpdate(id: $id, input: $input) {
+ success
+ issue {
+ id
+ identifier
+ title
+ }
+ }
+}`;
+
+async function main(config = {}) {
+ const target = config.target;
+ if (typeof target !== "string" || target.length > 100 || !LINEAR_ISSUE_PATTERN.test(target)) {
+ throw new Error(`${ERR_CONFIG}: linear_update_issue requires a valid configured target`);
+ }
+ if (config.allow_title !== true && config.allow_body !== true) {
+ throw new Error(`${ERR_CONFIG}: linear_update_issue must enable title or body updates`);
+ }
+
+ return async function handleLinearUpdateIssue(item) {
+ if (item?.title === undefined && item?.body === undefined) {
+ throw new Error(`${ERR_VALIDATION}: linear_update_issue requires title or body`);
+ }
+ if (item.title !== undefined && config.allow_title !== true) {
+ throw new Error(`${ERR_VALIDATION}: linear_update_issue title updates are not enabled`);
+ }
+ if (item.body !== undefined && config.allow_body !== true) {
+ throw new Error(`${ERR_VALIDATION}: linear_update_issue body updates are not enabled`);
+ }
+ if (item.title !== undefined && (typeof item.title !== "string" || !item.title.trim() || item.title.length > 256)) {
+ throw new Error(`${ERR_VALIDATION}: linear_update_issue title must be a non-empty string of at most 256 characters`);
+ }
+ if (item.body !== undefined && (typeof item.body !== "string" || item.body.length > 65536)) {
+ throw new Error(`${ERR_VALIDATION}: linear_update_issue body must be a string of at most 65536 characters`);
+ }
+
+ const input = {};
+ if (item.title !== undefined) {
+ input.title = sanitizeTitle(item.title, "", 256);
+ }
+ if (item.body !== undefined) {
+ input.description = sanitizeContent(item.body);
+ }
+
+ if (isStagedMode(config)) {
+ logStagedPreviewInfo(`Would update Linear issue ${target}`);
+ return { success: true, staged: true, target };
+ }
+
+ const data = await linearGraphQL(LINEAR_UPDATE_ISSUE, { id: target, input });
+ const payload = data?.issueUpdate;
+ if (payload?.success !== true || !payload.issue) {
+ throw new Error(`${ERR_API}: Linear issueUpdate did not return a successful issue`);
+ }
+ return {
+ success: true,
+ id: payload.issue.id,
+ identifier: payload.issue.identifier,
+ title: payload.issue.title,
+ };
+ };
+}
+
+module.exports = { LINEAR_UPDATE_ISSUE, main };
diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs
index d6ea1c9660d..ed00fa091a4 100644
--- a/actions/setup/js/safe_output_handler_manager.cjs
+++ b/actions/setup/js/safe_output_handler_manager.cjs
@@ -36,6 +36,9 @@ const GITHUB_TOKEN_CONFIG_KEY = "github-token";
* Maps safe output types to their handler module file paths
*/
const HANDLER_MAP = {
+ linear_create_issue: "./linear_create_issue.cjs",
+ linear_add_comment: "./linear_add_comment.cjs",
+ linear_update_issue: "./linear_update_issue.cjs",
create_issue: "./create_issue.cjs",
add_comment: "./add_comment.cjs",
comment_memory: "./comment_memory.cjs",
@@ -124,6 +127,8 @@ const WTD3_REQUIREMENT_ID = "WTD3";
* @type {Set}
*/
const THREAT_WARNING_REVIEWABLE_TYPES = new Set([
+ "linear_create_issue",
+ "linear_add_comment",
"create_issue",
"add_comment",
"create_pull_request",
@@ -163,6 +168,7 @@ const THREAT_WARNING_CONVERTIBLE_TYPES = new Map([["push_to_pull_request_branch"
* @type {Set}
*/
const THREAT_WARNING_ABORT_TYPES = new Set([
+ "linear_update_issue",
"noop",
"close_issue",
"link_sub_issue",
diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json
index 393e610b3d3..ce7b26b1ef3 100644
--- a/actions/setup/js/safe_outputs_tools.json
+++ b/actions/setup/js/safe_outputs_tools.json
@@ -1,4 +1,70 @@
[
+ {
+ "name": "linear_create_issue",
+ "description": "Create an issue in the Linear team fixed by safe-outputs.linear-create-issue.team-id. The Linear credential and team ID are trusted workflow configuration and are not agent inputs.",
+ "inputSchema": {
+ "type": "object",
+ "required": ["title", "body"],
+ "properties": {
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "description": "Final Linear issue title. Standard Safe Outputs title sanitization is applied.",
+ "x-safe-output-sanitization": "title"
+ },
+ "body": {
+ "type": "string",
+ "maxLength": 65536,
+ "description": "Linear issue description in Markdown. Standard Safe Outputs content sanitization is applied.",
+ "x-safe-output-sanitization": "content"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "linear_add_comment",
+ "description": "Add a comment to the Linear issue fixed by safe-outputs.linear-add-comment.target. The target and Linear credential are trusted workflow configuration and are not agent inputs.",
+ "inputSchema": {
+ "type": "object",
+ "required": ["body"],
+ "properties": {
+ "body": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 65536,
+ "description": "Comment body in Markdown. Standard Safe Outputs content sanitization is applied.",
+ "x-safe-output-sanitization": "content"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "linear_update_issue",
+ "description": "Update explicitly enabled fields on the Linear issue fixed by safe-outputs.linear-update-issue.target. The target and Linear credential are trusted workflow configuration and are not agent inputs.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "description": "Replacement Linear issue title. Standard Safe Outputs title sanitization is applied.",
+ "x-safe-output-sanitization": "title"
+ },
+ "body": {
+ "type": "string",
+ "maxLength": 65536,
+ "description": "Replacement Linear issue description in Markdown. Standard Safe Outputs content sanitization is applied.",
+ "x-safe-output-sanitization": "content"
+ }
+ },
+ "anyOf": [{ "required": ["title"] }, { "required": ["body"] }],
+ "additionalProperties": false
+ }
+ },
{
"name": "create_issue",
"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.",
diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md
index 02f37ba1afa..b616b5a2c0d 100644
--- a/docs/src/content/docs/reference/safe-outputs.md
+++ b/docs/src/content/docs/reference/safe-outputs.md
@@ -89,6 +89,34 @@ The tables below summarize the built-in safe output handlers. `noop`, `missing-t
| [Create Check Run](#check-run-creation-create-check-run) | `create-check-run` | Create GitHub Check Runs to surface analysis results in the PR checks UI (default max: 1, same-repo only) |
| [Create Agent Session](/gh-aw/reference/copilot-cloud-agent/#create-agent-session) | `create-agent-session` | Create Copilot coding agent sessions (max: 1) |
+### Linear
+
+| Output | Key | Description |
+|--------|-----|-------------|
+| [Create Linear Issue](#linear-safe-outputs) | `linear-create-issue` | Create an issue in a configured Linear team (max: 1) |
+| [Add Linear Comment](#linear-safe-outputs) | `linear-add-comment` | Comment on a configured Linear issue (max: 1) |
+| [Update Linear Issue](#linear-safe-outputs) | `linear-update-issue` | Update enabled fields on a configured Linear issue (max: 1) |
+
+#### Linear Safe Outputs
+
+Linear Safe Outputs use Linear's public GraphQL API from the isolated `safe_outputs` job. Configure a personal Linear API key through a secret expression. The credential is not available to the agent.
+
+```yaml wrap
+safe-outputs:
+ linear-token: ${{ secrets.LINEAR_API_KEY }}
+ linear-create-issue:
+ team-id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d"
+ max: 1
+ linear-add-comment:
+ target: "ENG-123"
+ linear-update-issue:
+ target: "ENG-123"
+ title: true
+ body: true
+```
+
+`team-id` is the Linear team model UUID, available through Linear's model UUID tooling or API. Comment and update targets are fixed trusted configuration and accept either a Linear issue model UUID or shorthand identifier such as `ENG-123`. Updates replace only the enabled `title` and `body` fields. All agent-provided titles, descriptions, and comments use standard Safe Outputs sanitization.
+
### System Types (Auto-Enabled)
| Output | Key | Description |
diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md
index 035fd4fb103..115301b6216 100644
--- a/docs/src/content/docs/specs/safe-outputs-specification.md
+++ b/docs/src/content/docs/specs/safe-outputs-specification.md
@@ -7,9 +7,9 @@ sidebar:
# Safe Outputs MCP Gateway Specification
-**Version**: 1.28.6
+**Version**: 1.29.0
**Status**: Working Draft
-**Publication Date**: 2026-08-24
+**Publication Date**: 2026-09-02
**Editor**: GitHub Agentic Workflows Team
**This Version**: [safe-outputs-specification](/gh-aw/specs/safe-outputs-specification/)
**Latest Published Version**: This document
@@ -385,6 +385,8 @@ jobs:
Agent execution context MUST NOT gain access to safe output job credentials through any mechanism (environment variables, file leaks, API endpoints, etc.).
+Safe Output Processors MAY target external APIs. Credentials for each external service MUST remain isolated to the privileged processor step for that service and MUST NOT enter agent environments, MCP schemas, prompts, operation artifacts, generated configuration files, logs, errors, or step summaries. Credentials for distinct services MUST NOT be substituted for or derived from one another.
+
**Verification**:
- **Method**: Manual security audit and code review
@@ -2742,6 +2744,69 @@ This section provides complete definitions for all remaining safe output types.
---
+#### Type: linear_create_issue
+
+**Purpose**: Create an issue in one trusted Linear team using Linear's public GraphQL API.
+
+**Configuration**:
+
+- `linear-token`: REQUIRED trusted secret expression containing a Linear personal API key
+- `linear-create-issue.team-id`: REQUIRED Linear team model UUID
+- `linear-create-issue.max`: Operation limit (default: 1)
+- `linear-create-issue.staged`: Staged mode override
+
+**MCP Tool**: `linear_create_issue`
+
+The MCP input object MUST require `title` and `body`, MUST reject additional properties, and MUST limit them to 256 and 65,536 characters respectively. The trusted team UUID and credential MUST NOT be MCP inputs.
+
+**Operational Semantics**:
+
+1. The processor MUST POST to the fixed `https://api.linear.app/graphql` endpoint using `Content-Type: application/json` and the API key as the raw `Authorization` header value.
+2. The implementation-defined `issueCreate(input: IssueCreateInput!)` document MUST use GraphQL variables for `teamId`, `title`, and `description`.
+3. `title` and `body` MUST undergo standard Safe Outputs title and content sanitization before `body` is mapped to Linear's `description`.
+4. The processor MUST fail on HTTP errors, malformed JSON, a non-empty top-level GraphQL `errors` array, `success: false`, or a missing issue object.
+5. Staged mode MUST validate and sanitize the request but MUST NOT perform a network mutation.
+
+The configured team ID MUST be a canonical UUID. Input exceeding a configured limit MUST be rejected, not silently truncated.
+
+#### Type: linear_add_comment
+
+**Purpose**: Add a comment to one trusted Linear issue.
+
+**Configuration**:
+
+- `linear-token`: REQUIRED trusted secret expression containing a Linear personal API key
+- `linear-add-comment.target`: REQUIRED fixed issue UUID or shorthand identifier such as `ENG-123`
+- `linear-add-comment.max`: Operation limit (default: 1)
+- `linear-add-comment.staged`: Staged mode override
+
+**MCP Tool**: `linear_add_comment`
+
+The MCP input object MUST require only `body`, MUST reject additional properties, and MUST limit the body to 65,536 characters. The target and credential MUST NOT be MCP inputs.
+
+The processor MUST use the fixed `commentCreate(input: CommentCreateInput!)` GraphQL document and pass the configured target as `issueId` through variables. The body MUST undergo standard Safe Outputs content sanitization. HTTP, parsing, GraphQL, unsuccessful-payload, and staged-mode behavior MUST match `linear_create_issue`.
+
+#### Type: linear_update_issue
+
+**Purpose**: Replace explicitly enabled basic fields on one trusted Linear issue.
+
+**Configuration**:
+
+- `linear-token`: REQUIRED trusted secret expression containing a Linear personal API key
+- `linear-update-issue.target`: REQUIRED fixed issue UUID or shorthand identifier such as `ENG-123`
+- `linear-update-issue.title`: Set to `true` to enable title replacement
+- `linear-update-issue.body`: Set to `true` to enable description replacement
+- `linear-update-issue.max`: Operation limit (default: 1)
+- `linear-update-issue.staged`: Staged mode override
+
+At least one of `title` or `body` MUST be enabled. The MCP tool name is `linear_update_issue`; its schema MUST expose only enabled fields, require at least one exposed field, reject additional properties, and apply the same field limits and sanitization as `linear_create_issue`. The target and credential MUST NOT be MCP inputs.
+
+The processor MUST use the fixed `issueUpdate(id: String!, input: IssueUpdateInput!)` GraphQL document. The configured target and update values MUST be GraphQL variables. `body` maps to Linear's `description`. Omitted fields MUST remain unchanged. HTTP, parsing, GraphQL, unsuccessful-payload, and staged-mode behavior MUST match `linear_create_issue`.
+
+For all Linear types, GraphQL source, endpoint, protocol, and host are implementation-defined and MUST NOT be agent-controlled. Linear API keys and GitHub App installation tokens are separate credentials for separate services. Enabling only Linear handlers MUST NOT request GitHub API write permissions or mint GitHub App tokens. Linear credentials MUST exist only in the trusted execution path and MUST NOT appear in handler configuration visible to the agent.
+
+---
+
#### Type: close_issue
**Purpose**: Close issues with closing comment explaining resolution.
@@ -5535,6 +5600,13 @@ This specification revision aligns with directly relevant `CHANGELOG.md` entries
- **Earlier changelog entry**: status comments were decoupled from default AI reaction behavior; explicit `on.status-comment` configuration is required when status comments are desired.
- **Earlier changelog entry**: `command` trigger was renamed to `slash_command` with deprecation compatibility.
+**Version 1.29.0** (2026-09-02):
+
+- **Added**: `linear_create_issue`, `linear_add_comment`, and `linear_update_issue` Safe Output definitions.
+- **Specified**: Fixed Linear GraphQL operations, variable-only dynamic values, API-key authentication, GraphQL error handling, target validation, standard content sanitization, staged execution, and credential isolation.
+- **Specified**: Linear-only handlers do not request GitHub write permissions or use GitHub App installation tokens.
+- **Updated**: Publication metadata to 1.29.0.
+
**Version 1.28.6** (2026-08-24):
- **Specified**: Pre-created pull request branches MUST be validated after creation and before downstream jobs treat them as trusted workflow state.
diff --git a/pkg/parser/schema_linear_safe_outputs_test.go b/pkg/parser/schema_linear_safe_outputs_test.go
new file mode 100644
index 00000000000..446d4841943
--- /dev/null
+++ b/pkg/parser/schema_linear_safe_outputs_test.go
@@ -0,0 +1,88 @@
+//go:build !integration
+
+package parser
+
+import "testing"
+
+func TestMainWorkflowSchemaLinearSafeOutputs(t *testing.T) {
+ t.Parallel()
+
+ valid := map[string]any{
+ "on": "push",
+ "engine": "copilot",
+ "safe-outputs": map[string]any{
+ "linear-token": "${{ secrets.LINEAR_API_KEY }}",
+ "linear-create-issue": map[string]any{
+ "team-id": "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
+ "max": 1,
+ },
+ "linear-add-comment": map[string]any{
+ "target": "ENG-123",
+ },
+ "linear-update-issue": map[string]any{
+ "target": "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
+ "title": true,
+ "body": true,
+ },
+ },
+ }
+ if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(valid, "/tmp/linear-valid.md"); err != nil {
+ t.Fatalf("expected valid Linear safe outputs configuration: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ safeOutputs map[string]any
+ }{
+ {
+ name: "missing token",
+ safeOutputs: map[string]any{
+ "linear-create-issue": map[string]any{"team-id": "9cfb482a-81e3-4154-b5b9-2c805e70a02d"},
+ },
+ },
+ {
+ name: "missing team ID",
+ safeOutputs: map[string]any{
+ "linear-token": "${{ secrets.LINEAR_API_KEY }}",
+ "linear-create-issue": map[string]any{},
+ },
+ },
+ {
+ name: "malformed target",
+ safeOutputs: map[string]any{
+ "linear-token": "${{ secrets.LINEAR_API_KEY }}",
+ "linear-add-comment": map[string]any{"target": "https://api.linear.app"},
+ },
+ },
+ {
+ name: "literal token",
+ safeOutputs: map[string]any{
+ "linear-token": "lin_api_secret",
+ "linear-add-comment": map[string]any{"target": "ENG-123"},
+ },
+ },
+ {
+ name: "no update fields",
+ safeOutputs: map[string]any{
+ "linear-token": "${{ secrets.LINEAR_API_KEY }}",
+ "linear-update-issue": map[string]any{"target": "ENG-123"},
+ },
+ },
+ {
+ name: "unknown field",
+ safeOutputs: map[string]any{
+ "linear-token": "${{ secrets.LINEAR_API_KEY }}",
+ "linear-add-comment": map[string]any{"target": "ENG-123", "endpoint": "https://example.com"},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ frontmatter := map[string]any{"on": "push", "engine": "copilot", "safe-outputs": tt.safeOutputs}
+ if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/linear-invalid.md"); err == nil {
+ t.Fatal("expected schema validation to reject malformed Linear safe outputs configuration")
+ }
+ })
+ }
+}
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index b94416c9780..fd1b5a67952 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -7315,6 +7315,94 @@
],
"description": "Enable AI agents to approve pending workflow runs in the action required state."
},
+ "linear-create-issue": {
+ "type": "object",
+ "description": "Create Linear issues through the isolated safe_outputs job.",
+ "properties": {
+ "team-id": {
+ "type": "string",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
+ "description": "Trusted Linear team model UUID."
+ },
+ "max": {
+ "oneOf": [
+ { "type": "integer", "minimum": 1, "maximum": 100 },
+ { "type": "string", "pattern": "^\\$\\{\\{.*\\}\\}$" }
+ ],
+ "description": "Maximum number of Linear issues to create (default: 1)."
+ },
+ "staged": { "$ref": "#/$defs/templatable_boolean" },
+ "samples": {
+ "oneOf": [
+ { "type": "array", "items": { "type": "object", "additionalProperties": true } },
+ { "type": "object", "additionalProperties": true }
+ ]
+ }
+ },
+ "required": ["team-id"],
+ "additionalProperties": false
+ },
+ "linear-add-comment": {
+ "type": "object",
+ "description": "Add comments to one trusted Linear issue through the isolated safe_outputs job.",
+ "properties": {
+ "target": {
+ "$ref": "#/$defs/linear_issue_identifier",
+ "description": "Trusted Linear issue UUID or shorthand identifier such as ENG-123."
+ },
+ "max": {
+ "oneOf": [
+ { "type": "integer", "minimum": 1, "maximum": 100 },
+ { "type": "string", "pattern": "^\\$\\{\\{.*\\}\\}$" }
+ ],
+ "description": "Maximum number of Linear comments to add (default: 1)."
+ },
+ "staged": { "$ref": "#/$defs/templatable_boolean" },
+ "samples": {
+ "oneOf": [
+ { "type": "array", "items": { "type": "object", "additionalProperties": true } },
+ { "type": "object", "additionalProperties": true }
+ ]
+ }
+ },
+ "required": ["target"],
+ "additionalProperties": false
+ },
+ "linear-update-issue": {
+ "type": "object",
+ "description": "Update explicitly enabled fields on one trusted Linear issue through the isolated safe_outputs job.",
+ "properties": {
+ "target": {
+ "$ref": "#/$defs/linear_issue_identifier",
+ "description": "Trusted Linear issue UUID or shorthand identifier such as ENG-123."
+ },
+ "title": {
+ "const": true,
+ "description": "Allow the agent to update the Linear issue title."
+ },
+ "body": {
+ "const": true,
+ "description": "Allow the agent to replace the Linear issue description."
+ },
+ "max": {
+ "oneOf": [
+ { "type": "integer", "minimum": 1, "maximum": 100 },
+ { "type": "string", "pattern": "^\\$\\{\\{.*\\}\\}$" }
+ ],
+ "description": "Maximum number of updates to apply (default: 1)."
+ },
+ "staged": { "$ref": "#/$defs/templatable_boolean" },
+ "samples": {
+ "oneOf": [
+ { "type": "array", "items": { "type": "object", "additionalProperties": true } },
+ { "type": "object", "additionalProperties": true }
+ ]
+ }
+ },
+ "required": ["target"],
+ "anyOf": [{ "required": ["title"] }, { "required": ["body"] }],
+ "additionalProperties": false
+ },
"add-comment": {
"oneOf": [
{
@@ -11305,6 +11393,11 @@
"description": "GitHub token to use for safe output jobs. Typically a secret reference like ${{ secrets.GITHUB_TOKEN }} or ${{ secrets.CUSTOM_PAT }}",
"examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.CUSTOM_PAT }}", "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}"]
},
+ "linear-token": {
+ "$ref": "#/$defs/linear_token",
+ "description": "Linear personal API key expression used only by the trusted safe_outputs job.",
+ "examples": ["${{ secrets.LINEAR_API_KEY }}"]
+ },
"github-app": {
"$ref": "#/$defs/github_app",
"description": "GitHub App credentials for minting installation access tokens. When configured, a token will be generated using the app credentials and used for all safe output operations."
@@ -12240,6 +12333,16 @@
"description": "Enable AI agents to replace one label with another on GitHub issues or pull requests in a single atomic operation. Ideal for maintaining label-based state machines (e.g. transitioning issues through workflow states)."
}
},
+ "allOf": [
+ {
+ "if": {
+ "anyOf": [{ "required": ["linear-create-issue"] }, { "required": ["linear-add-comment"] }, { "required": ["linear-update-issue"] }]
+ },
+ "then": {
+ "required": ["linear-token"]
+ }
+ }
+ ],
"additionalProperties": false
},
"secret-masking": {
@@ -14615,6 +14718,18 @@
"description": "GitHub token expression for same-job contexts. Accepts a secrets expression (e.g., `${{ secrets.NAME }}` or `${{ secrets.NAME1 || secrets.NAME2 }}`), a cross-job output expression (e.g., `${{ needs.auth.outputs.token }}`), or a same-job step output expression (e.g., `${{ steps.fetch-token.outputs.my-token }}`). Pattern details: secret names and job IDs match `[A-Za-z_][A-Za-z0-9_]*`; same-job step IDs and output names match `[A-Za-z_][A-Za-z0-9_-]*`.",
"examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.CUSTOM_PAT }}", "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}", "${{ needs.auth.outputs.token }}", "${{ steps.fetch-token.outputs.my-token }}"]
},
+ "linear_token": {
+ "type": "string",
+ "pattern": "^\\$\\{\\{\\s*(secrets\\.[A-Za-z_][A-Za-z0-9_]*(\\s*\\|\\|\\s*secrets\\.[A-Za-z_][A-Za-z0-9_]*)*|needs\\.[A-Za-z_][A-Za-z0-9_]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_]*|steps\\.[A-Za-z_][A-Za-z0-9_-]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_-]*)\\s*\\}\\}$",
+ "description": "Linear API key expression resolved only in the trusted safe_outputs job."
+ },
+ "linear_issue_identifier": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Z][A-Z0-9]{0,15}-[1-9][0-9]*)$",
+ "description": "Linear issue model UUID or uppercase shorthand identifier."
+ },
"github_app": {
"type": "object",
"description": "GitHub App credentials for minting installation access tokens.",
diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go
index b2126975854..5866ae2d51b 100644
--- a/pkg/workflow/compiler_safe_outputs_job.go
+++ b/pkg/workflow/compiler_safe_outputs_job.go
@@ -324,6 +324,9 @@ type safeOutputsHandlerOutputsAndActionState struct {
// processed by the consolidated handler manager step (as opposed to a dedicated job/step).
func hasHandlerManagerTypes(data *WorkflowData) bool {
return data.SafeOutputs.CreateIssues != nil ||
+ data.SafeOutputs.LinearCreateIssue != nil ||
+ data.SafeOutputs.LinearAddComment != nil ||
+ data.SafeOutputs.LinearUpdateIssue != nil ||
data.SafeOutputs.AddComments != nil ||
data.SafeOutputs.CreateDiscussions != nil ||
data.SafeOutputs.CloseIssues != nil ||
@@ -403,6 +406,7 @@ func (c *Compiler) appendHandlerManagerStep(data *WorkflowData, state *safeOutpu
if err != nil {
return err
}
+ handlerManagerSteps = injectLinearTokenEnv(handlerManagerSteps, data.SafeOutputs)
state.steps = append(state.steps, handlerManagerSteps...)
state.safeOutputStepNames = append(state.safeOutputStepNames, "process_safe_outputs")
addHandlerManagerOutputs(data, state.outputs)
diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json
index 393e610b3d3..ce7b26b1ef3 100644
--- a/pkg/workflow/js/safe_outputs_tools.json
+++ b/pkg/workflow/js/safe_outputs_tools.json
@@ -1,4 +1,70 @@
[
+ {
+ "name": "linear_create_issue",
+ "description": "Create an issue in the Linear team fixed by safe-outputs.linear-create-issue.team-id. The Linear credential and team ID are trusted workflow configuration and are not agent inputs.",
+ "inputSchema": {
+ "type": "object",
+ "required": ["title", "body"],
+ "properties": {
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "description": "Final Linear issue title. Standard Safe Outputs title sanitization is applied.",
+ "x-safe-output-sanitization": "title"
+ },
+ "body": {
+ "type": "string",
+ "maxLength": 65536,
+ "description": "Linear issue description in Markdown. Standard Safe Outputs content sanitization is applied.",
+ "x-safe-output-sanitization": "content"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "linear_add_comment",
+ "description": "Add a comment to the Linear issue fixed by safe-outputs.linear-add-comment.target. The target and Linear credential are trusted workflow configuration and are not agent inputs.",
+ "inputSchema": {
+ "type": "object",
+ "required": ["body"],
+ "properties": {
+ "body": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 65536,
+ "description": "Comment body in Markdown. Standard Safe Outputs content sanitization is applied.",
+ "x-safe-output-sanitization": "content"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "linear_update_issue",
+ "description": "Update explicitly enabled fields on the Linear issue fixed by safe-outputs.linear-update-issue.target. The target and Linear credential are trusted workflow configuration and are not agent inputs.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "description": "Replacement Linear issue title. Standard Safe Outputs title sanitization is applied.",
+ "x-safe-output-sanitization": "title"
+ },
+ "body": {
+ "type": "string",
+ "maxLength": 65536,
+ "description": "Replacement Linear issue description in Markdown. Standard Safe Outputs content sanitization is applied.",
+ "x-safe-output-sanitization": "content"
+ }
+ },
+ "anyOf": [{ "required": ["title"] }, { "required": ["body"] }],
+ "additionalProperties": false
+ }
+ },
{
"name": "create_issue",
"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.",
diff --git a/pkg/workflow/linear_safe_outputs.go b/pkg/workflow/linear_safe_outputs.go
new file mode 100644
index 00000000000..a419c412900
--- /dev/null
+++ b/pkg/workflow/linear_safe_outputs.go
@@ -0,0 +1,92 @@
+package workflow
+
+import (
+ "fmt"
+
+ "github.com/github/gh-aw/pkg/logger"
+)
+
+var linearSafeOutputsLog = logger.New("workflow:linear_safe_outputs")
+
+type LinearCreateIssueConfig struct {
+ BaseSafeOutputConfig `yaml:",inline"`
+ TeamID string `yaml:"team-id"`
+}
+
+type LinearTargetConfig struct {
+ BaseSafeOutputConfig `yaml:",inline"`
+ Target string `yaml:"target"`
+}
+
+type LinearUpdateIssueConfig struct {
+ LinearTargetConfig `yaml:",inline"`
+ Title *bool `yaml:"title,omitempty"`
+ Body *bool `yaml:"body,omitempty"`
+}
+
+func preprocessLinearBaseConfig(outputMap map[string]any, key string) {
+ configData, _ := outputMap[key].(map[string]any)
+ if configData == nil {
+ return
+ }
+ if err := preprocessIntFieldAsString(configData, "max", linearSafeOutputsLog); err != nil {
+ linearSafeOutputsLog.Printf("Invalid %s max value: %v", key, err)
+ }
+ if err := preprocessBoolFieldAsString(configData, "staged", linearSafeOutputsLog); err != nil {
+ linearSafeOutputsLog.Printf("Invalid %s staged value: %v", key, err)
+ }
+}
+
+func parseLinearConfig[T any](outputMap map[string]any, key string) *T {
+ preprocessLinearBaseConfig(outputMap, key)
+ return parseConfigScaffoldWithPostProcess(outputMap, key, linearSafeOutputsLog,
+ func(err error) *T {
+ linearSafeOutputsLog.Printf("Failed to unmarshal %s config: %v", key, err)
+ return nil
+ },
+ func(config *T) {
+ if base := linearBaseConfig(config); base != nil && base.Max == nil {
+ base.Max = defaultIntStr(1)
+ }
+ })
+}
+
+func linearBaseConfig(config any) *BaseSafeOutputConfig {
+ switch c := config.(type) {
+ case *LinearCreateIssueConfig:
+ return &c.BaseSafeOutputConfig
+ case *LinearTargetConfig:
+ return &c.BaseSafeOutputConfig
+ case *LinearUpdateIssueConfig:
+ return &c.BaseSafeOutputConfig
+ default:
+ return nil
+ }
+}
+
+func (c *Compiler) parseLinearCreateIssueConfig(outputMap map[string]any) *LinearCreateIssueConfig {
+ return parseLinearConfig[LinearCreateIssueConfig](outputMap, "linear-create-issue")
+}
+
+func (c *Compiler) parseLinearAddCommentConfig(outputMap map[string]any) *LinearTargetConfig {
+ return parseLinearConfig[LinearTargetConfig](outputMap, "linear-add-comment")
+}
+
+func (c *Compiler) parseLinearUpdateIssueConfig(outputMap map[string]any) *LinearUpdateIssueConfig {
+ return parseLinearConfig[LinearUpdateIssueConfig](outputMap, "linear-update-issue")
+}
+
+func injectLinearTokenEnv(steps []string, config *SafeOutputsConfig) []string {
+ if config == nil || config.LinearToken == "" ||
+ (config.LinearCreateIssue == nil && config.LinearAddComment == nil && config.LinearUpdateIssue == nil) {
+ return steps
+ }
+
+ for index, step := range steps {
+ if step == " env:\n" {
+ tokenEnv := fmt.Sprintf(" GH_AW_LINEAR_TOKEN: %s\n", config.LinearToken)
+ return append(steps[:index+1], append([]string{tokenEnv}, steps[index+1:]...)...)
+ }
+ }
+ return steps
+}
diff --git a/pkg/workflow/linear_safe_outputs_test.go b/pkg/workflow/linear_safe_outputs_test.go
new file mode 100644
index 00000000000..af45f3fb055
--- /dev/null
+++ b/pkg/workflow/linear_safe_outputs_test.go
@@ -0,0 +1,97 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExtractLinearSafeOutputsConfig(t *testing.T) {
+ compiler := NewCompiler()
+ config := compiler.extractSafeOutputsConfig(map[string]any{
+ "safe-outputs": map[string]any{
+ "linear-token": "${{ secrets.LINEAR_API_KEY }}",
+ "linear-create-issue": map[string]any{
+ "team-id": "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
+ },
+ "linear-add-comment": map[string]any{
+ "target": "ENG-123",
+ },
+ "linear-update-issue": map[string]any{
+ "target": "ENG-456",
+ "title": true,
+ },
+ },
+ })
+
+ require.NotNil(t, config)
+ assert.Equal(t, "${{ secrets.LINEAR_API_KEY }}", config.LinearToken)
+ require.NotNil(t, config.LinearCreateIssue)
+ assert.Equal(t, "9cfb482a-81e3-4154-b5b9-2c805e70a02d", config.LinearCreateIssue.TeamID)
+ assert.Equal(t, "1", *config.LinearCreateIssue.Max)
+ require.NotNil(t, config.LinearAddComment)
+ assert.Equal(t, "ENG-123", config.LinearAddComment.Target)
+ require.NotNil(t, config.LinearUpdateIssue)
+ assert.Equal(t, "ENG-456", config.LinearUpdateIssue.Target)
+ require.NotNil(t, config.LinearUpdateIssue.Title)
+ assert.True(t, *config.LinearUpdateIssue.Title)
+}
+
+func TestLinearHandlerConfigExcludesCredential(t *testing.T) {
+ config := &SafeOutputsConfig{
+ LinearToken: "${{ secrets.LINEAR_API_KEY }}",
+ LinearCreateIssue: &LinearCreateIssueConfig{
+ BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")},
+ TeamID: "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
+ },
+ LinearAddComment: &LinearTargetConfig{
+ BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("2")},
+ Target: "ENG-123",
+ },
+ LinearUpdateIssue: &LinearUpdateIssueConfig{
+ LinearTargetConfig: LinearTargetConfig{Target: "ENG-456"},
+ Title: ptrBool(true),
+ },
+ }
+
+ result, err := generateSafeOutputsConfig(&WorkflowData{SafeOutputs: config})
+ require.NoError(t, err)
+ assert.NotContains(t, result, "LINEAR_API_KEY")
+ assert.NotContains(t, result, "linear-token")
+
+ var parsed map[string]any
+ require.NoError(t, json.Unmarshal([]byte(result), &parsed))
+ assert.Equal(t, "ENG-123", parsed["linear_add_comment"].(map[string]any)["target"])
+ assert.Equal(t, true, parsed["linear_update_issue"].(map[string]any)["allow_title"])
+}
+
+func TestLinearSafeOutputsNeedNoGitHubWritePermissions(t *testing.T) {
+ config := &SafeOutputsConfig{
+ LinearCreateIssue: &LinearCreateIssueConfig{},
+ LinearAddComment: &LinearTargetConfig{},
+ LinearUpdateIssue: &LinearUpdateIssueConfig{},
+ }
+ permissions := computePermissionsForSafeOutputs(config, false)
+ require.NotNil(t, permissions)
+ assert.Empty(t, permissions.permissions)
+}
+
+func TestLinearTokenOnlyAddedToTrustedProcessingStep(t *testing.T) {
+ data := &WorkflowData{SafeOutputs: &SafeOutputsConfig{
+ LinearToken: "${{ secrets.LINEAR_API_KEY }}",
+ LinearCreateIssue: &LinearCreateIssueConfig{TeamID: "9cfb482a-81e3-4154-b5b9-2c805e70a02d"},
+ }}
+ compiler := NewCompiler()
+ steps, err := compiler.buildHandlerManagerStep(data)
+ require.NoError(t, err)
+ steps = injectLinearTokenEnv(steps, data.SafeOutputs)
+ rendered := strings.Join(steps, "")
+
+ assert.Contains(t, rendered, "GH_AW_LINEAR_TOKEN: ${{ secrets.LINEAR_API_KEY }}")
+ assert.NotContains(t, rendered, "linear-token")
+}
diff --git a/pkg/workflow/safe_output_handlers.go b/pkg/workflow/safe_output_handlers.go
index 8bf2c23c71b..77ca56b4292 100644
--- a/pkg/workflow/safe_output_handlers.go
+++ b/pkg/workflow/safe_output_handlers.go
@@ -21,6 +21,24 @@ type safeOutputHandlerDescriptor struct {
}
var safeOutputHandlers = []safeOutputHandlerDescriptor{
+ {
+ Key: "linear-create-issue",
+ StructField: "LinearCreateIssue",
+ ToolName: "linear_create_issue",
+ NewConfig: func() any { return &LinearCreateIssueConfig{} },
+ },
+ {
+ Key: "linear-add-comment",
+ StructField: "LinearAddComment",
+ ToolName: "linear_add_comment",
+ NewConfig: func() any { return &LinearTargetConfig{} },
+ },
+ {
+ Key: "linear-update-issue",
+ StructField: "LinearUpdateIssue",
+ ToolName: "linear_update_issue",
+ NewConfig: func() any { return &LinearUpdateIssueConfig{} },
+ },
{
Key: "create-issue",
StructField: "CreateIssues",
diff --git a/pkg/workflow/safe_outputs_config_extraction.go b/pkg/workflow/safe_outputs_config_extraction.go
index 52532df2ff1..0206c417111 100644
--- a/pkg/workflow/safe_outputs_config_extraction.go
+++ b/pkg/workflow/safe_outputs_config_extraction.go
@@ -42,6 +42,8 @@ package workflow
//
// extractSafeOutputsConfig extracts output configuration from frontmatter
+//
+//nolint:largefunc // Legacy extraction remains centralized while handler parsers are incrementally migrated.
func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOutputsConfig {
safeOutputsConfigLog.Print("Extracting safe-outputs configuration from frontmatter")
@@ -52,6 +54,10 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut
safeOutputsConfigLog.Printf("Processing safe-outputs configuration with %d top-level keys", len(outputMap))
config = &SafeOutputsConfig{}
+ config.LinearCreateIssue = c.parseLinearCreateIssueConfig(outputMap)
+ config.LinearAddComment = c.parseLinearAddCommentConfig(outputMap)
+ config.LinearUpdateIssue = c.parseLinearUpdateIssueConfig(outputMap)
+
// Handle create-issue
issuesConfig := c.parseCreateIssuesConfig(outputMap)
if issuesConfig != nil {
diff --git a/pkg/workflow/safe_outputs_config_global.go b/pkg/workflow/safe_outputs_config_global.go
index 90c3cd920a7..e0108249ac3 100644
--- a/pkg/workflow/safe_outputs_config_global.go
+++ b/pkg/workflow/safe_outputs_config_global.go
@@ -10,6 +10,8 @@ import (
// extractGlobalConfigFields parses safe-outputs fields that apply across handlers,
// keeping extractSafeOutputsConfig focused on routing handler-specific configuration.
+//
+//nolint:largefunc // Legacy global configuration extraction is intentionally centralized.
func (c *Compiler) extractGlobalConfigFields(outputMap map[string]any, config *SafeOutputsConfig) {
// Handle steering issue mode.
if steer, exists := outputMap["steer"]; exists {
@@ -90,6 +92,12 @@ func (c *Compiler) extractGlobalConfigFields(outputMap map[string]any, config *S
}
}
+ if linearToken, exists := outputMap["linear-token"]; exists {
+ if linearTokenStr, ok := linearToken.(string); ok {
+ config.LinearToken = linearTokenStr
+ }
+ }
+
// Handle max-patch-size configuration
config.MaximumPatchSize = parseBoundedIntFieldOrDefault(outputMap, "max-patch-size", 4096, safeOutputsConfigLog)
diff --git a/pkg/workflow/safe_outputs_config_types.go b/pkg/workflow/safe_outputs_config_types.go
index 44acd0d6e48..fbf8eba60fd 100644
--- a/pkg/workflow/safe_outputs_config_types.go
+++ b/pkg/workflow/safe_outputs_config_types.go
@@ -40,6 +40,9 @@ type BaseSafeOutputConfig struct {
type SafeOutputsConfig struct {
Steer bool `yaml:"steer,omitempty"` // Experimental. Create an issue and steer the agent from issue comments.
CreateIssues *CreateIssuesConfig `yaml:"create-issue,omitempty"`
+ LinearCreateIssue *LinearCreateIssueConfig `yaml:"linear-create-issue,omitempty"`
+ LinearAddComment *LinearTargetConfig `yaml:"linear-add-comment,omitempty"`
+ LinearUpdateIssue *LinearUpdateIssueConfig `yaml:"linear-update-issue,omitempty"`
CreateDiscussions *CreateDiscussionsConfig `yaml:"create-discussion,omitempty"`
UpdateDiscussions *UpdateDiscussionsConfig `yaml:"update-discussion,omitempty"`
CloseDiscussions *CloseDiscussionsConfig `yaml:"close-discussion,omitempty"`
@@ -102,6 +105,7 @@ type SafeOutputsConfig struct {
Staged *TemplatableBool `yaml:"staged,omitempty"` // Templatable preview-only mode for all safe outputs
Env map[string]string `yaml:"env,omitempty"` // Environment variables to pass to safe output jobs
GitHubToken string `yaml:"github-token,omitempty"` // GitHub token for safe output jobs
+ LinearToken string `yaml:"linear-token,omitempty"` // Linear API key for Linear safe output handlers
MaximumPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB (defaults to 4096)
MaximumPatchFiles int `yaml:"max-patch-files,omitempty"` // Maximum allowed unique files per create-pull-request patch (defaults to 100)
RunsOn string `yaml:"runs-on,omitempty"` // Runner configuration for safe-outputs jobs
diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go
index 4da807f205c..8dfea42ae6f 100644
--- a/pkg/workflow/safe_outputs_handler_registry.go
+++ b/pkg/workflow/safe_outputs_handler_registry.go
@@ -62,6 +62,7 @@ var handlerRegistry = mergeHandlerMaps(
projectHandlerRegistry,
assignmentHandlerRegistry,
commentHandlerRegistry,
+ linearHandlerRegistry,
releaseHandlerRegistry,
diagnosticHandlerRegistry,
)
diff --git a/pkg/workflow/safe_outputs_handler_registry_linear.go b/pkg/workflow/safe_outputs_handler_registry_linear.go
new file mode 100644
index 00000000000..b8ffb378435
--- /dev/null
+++ b/pkg/workflow/safe_outputs_handler_registry_linear.go
@@ -0,0 +1,44 @@
+package workflow
+
+var linearHandlerRegistry = map[string]handlerBuilder{
+ "linear_create_issue": func(cfg *SafeOutputsConfig) map[string]any {
+ if cfg.LinearCreateIssue == nil {
+ return nil
+ }
+ c := cfg.LinearCreateIssue
+ return newHandlerConfigBuilder().
+ AddTemplatableInt("max", c.Max).
+ AddIfNotEmpty("team_id", c.TeamID).
+ AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)).
+ Build()
+ },
+ "linear_add_comment": func(cfg *SafeOutputsConfig) map[string]any {
+ if cfg.LinearAddComment == nil {
+ return nil
+ }
+ c := cfg.LinearAddComment
+ return newHandlerConfigBuilder().
+ AddTemplatableInt("max", c.Max).
+ AddIfNotEmpty("target", c.Target).
+ AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)).
+ Build()
+ },
+ "linear_update_issue": func(cfg *SafeOutputsConfig) map[string]any {
+ if cfg.LinearUpdateIssue == nil {
+ return nil
+ }
+ c := cfg.LinearUpdateIssue
+ builder := newHandlerConfigBuilder().
+ AddTemplatableInt("max", c.Max).
+ AddIfNotEmpty("target", c.Target)
+ if c.Title != nil && *c.Title {
+ builder.AddDefault("allow_title", true)
+ }
+ if c.Body != nil && *c.Body {
+ builder.AddDefault("allow_body", true)
+ }
+ return builder.
+ AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)).
+ Build()
+ },
+}
diff --git a/pkg/workflow/safe_outputs_tools_computation.go b/pkg/workflow/safe_outputs_tools_computation.go
index e4585537ab1..e0389cde88b 100644
--- a/pkg/workflow/safe_outputs_tools_computation.go
+++ b/pkg/workflow/safe_outputs_tools_computation.go
@@ -7,6 +7,8 @@ var safeOutputsToolsComputationLog = logger.New("workflow:safe_outputs_tools_com
// computeEnabledToolNames returns the set of predefined tool names that are enabled
// by the workflow's SafeOutputsConfig. Dynamic tools (dispatch-workflow, custom jobs,
// call-workflow) are excluded because they are generated separately.
+//
+//nolint:largefunc // Built-in tool enablement is kept as one exhaustive mapping.
func computeEnabledToolNames(data *WorkflowData) map[string]struct {
} {
enabledTools := make(map[string]struct {
@@ -20,6 +22,15 @@ func computeEnabledToolNames(data *WorkflowData) map[string]struct {
enabledTools["create_issue"] = struct {
}{}
}
+ if data.SafeOutputs.LinearCreateIssue != nil {
+ enabledTools["linear_create_issue"] = struct{}{}
+ }
+ if data.SafeOutputs.LinearAddComment != nil {
+ enabledTools["linear_add_comment"] = struct{}{}
+ }
+ if data.SafeOutputs.LinearUpdateIssue != nil {
+ enabledTools["linear_update_issue"] = struct{}{}
+ }
if data.SafeOutputs.CreateAgentSessions != nil {
enabledTools["create_agent_session"] = struct {
}{}
From 228ce2ff287d5c5e48095742764870e4b2efa66c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Sep 2026 01:54:42 +0000
Subject: [PATCH 2/7] Complete Linear validation and isolation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../mattpocock-skills-reviewer.lock.yml | 10 +++---
.../workflows/mattpocock-skills-reviewer.md | 2 +-
actions/setup/js/linear_add_comment.cjs | 7 ++--
actions/setup/js/linear_create_issue.cjs | 6 ++--
actions/setup/js/linear_graphql.cjs | 9 +++--
actions/setup/js/linear_safe_outputs.test.cjs | 8 ++---
actions/setup/js/linear_update_issue.cjs | 13 ++++---
actions/setup/js/safe_outputs_tools.json | 11 +++---
.../docs/specs/safe-outputs-specification.md | 4 +--
pkg/workflow/js/safe_outputs_tools.json | 11 +++---
pkg/workflow/linear_safe_outputs.go | 35 +++++++++++++++++++
pkg/workflow/linear_safe_outputs_test.go | 27 ++++++++++++++
.../safe_outputs_config_extraction.go | 2 +-
.../safe_outputs_handler_registry_test.go | 4 +++
pkg/workflow/safe_outputs_max_validation.go | 23 ++++++++++++
pkg/workflow/safe_outputs_state.go | 12 +++++--
.../safe_outputs_validation_config.go | 22 ++++++++++++
17 files changed, 166 insertions(+), 40 deletions(-)
diff --git a/.github/workflows/mattpocock-skills-reviewer.lock.yml b/.github/workflows/mattpocock-skills-reviewer.lock.yml
index 9a1dc908393..6335587b77c 100644
--- a/.github/workflows/mattpocock-skills-reviewer.lock.yml
+++ b/.github/workflows/mattpocock-skills-reviewer.lock.yml
@@ -1,4 +1,4 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c29ad5495416df4ab2bd37f0bd172d5e406a211c95240314faf41209ca722af5","body_hash":"4f8ea90727525c0b8c4fc054a85b41ef5a1d158a6d23720c4ba5529510fb832e","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-4.6","engine_versions":{"copilot":"1.0.80"}}
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"0330bf6b59ad5e0400b64da12f5f49200c1e0be7e109e61efd27f97107642a1a","body_hash":"4f8ea90727525c0b8c4fc054a85b41ef5a1d158a6d23720c4ba5529510fb832e","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-5","engine_versions":{"copilot":"1.0.80"}}
# gh-aw-manifest: {"version":1,"secrets":["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","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"skills":["mattpocock/skills/codebase-design@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/diagnosing-bugs@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/grill-with-docs@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/improve-codebase-architecture@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/tdd@801dca688564c529fa84f247f64472520d9ebe28"],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.12","digest":"sha256:390051be4ed1847f774fd8980b61d3a3523574c0175d00c3fc7cdf2002a88202","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.12@sha256:390051be4ed1847f774fd8980b61d3a3523574c0175d00c3fc7cdf2002a88202"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.12","digest":"sha256:d7d533d87c80d87ff91ac0e21e9299055c3beedff1536262b97ed700fb065a32","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.12@sha256:d7d533d87c80d87ff91ac0e21e9299055c3beedff1536262b97ed700fb065a32"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.28.12","digest":"sha256:5250629d48eaedfedf2e948785228e8da29eec2a83cbab58ea0751c14a7b021d","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.28.12@sha256:5250629d48eaedfedf2e948785228e8da29eec2a83cbab58ea0751c14a7b021d"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.12","digest":"sha256:52c34aca98d2a6833c329f1505912a6949c4fda16618c010c979bd59ea99254f","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.12@sha256:52c34aca98d2a6833c329f1505912a6949c4fda16618c010c979bd59ea99254f"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.14","digest":"sha256:b2f0c2b2f17b5fbe809e5bb99dc185b6ddd70df25295dc63a6d526350334eff5","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.14@sha256:b2f0c2b2f17b5fbe809e5bb99dc185b6ddd70df25295dc63a6d526350334eff5"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"image":"ghcr.io/github/github-mcp-server:v1.11.0","digest":"sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699","pinned_image":"ghcr.io/github/github-mcp-server:v1.11.0@sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699"}],"has_pull_request":true,"mcp_servers":[{"name":"safeoutputs","tools":["add_comment","create_check_run","create_pull_request_review_comment","missing_data","missing_tool","noop","submit_pull_request_review"]}]}
# 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
#
@@ -166,7 +166,7 @@ jobs:
env:
GH_AW_INFO_ENGINE_ID: "copilot"
GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
- GH_AW_INFO_MODEL: "claude-sonnet-4.6"
+ GH_AW_INFO_MODEL: "claude-sonnet-5"
GH_AW_INFO_VERSION: "1.0.80"
GH_AW_INFO_AGENT_VERSION: "1.0.80"
GH_AW_INFO_WORKFLOW_NAME: "Matt Pocock Skills Reviewer"
@@ -1210,7 +1210,7 @@ jobs:
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
COPILOT_GITHUB_TOKEN: ${{ github.token }}
- COPILOT_MODEL: claude-sonnet-4.6
+ COPILOT_MODEL: claude-sonnet-5
GH_AW_LLM_PROVIDER: github
GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
@@ -1898,7 +1898,7 @@ jobs:
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
COPILOT_GITHUB_TOKEN: ${{ github.token }}
- COPILOT_MODEL: claude-sonnet-4.6
+ COPILOT_MODEL: claude-sonnet-5
GH_AW_HARNESS_MAX_RETRIES: 0
GH_AW_LLM_PROVIDER: github
GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}
@@ -2107,7 +2107,7 @@ jobs:
GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
GH_AW_ENGINE_ID: "copilot"
- GH_AW_ENGINE_MODEL: "claude-sonnet-4.6"
+ GH_AW_ENGINE_MODEL: "claude-sonnet-5"
GH_AW_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
GH_AW_PROJECT_UTC: "-08:00"
GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
diff --git a/.github/workflows/mattpocock-skills-reviewer.md b/.github/workflows/mattpocock-skills-reviewer.md
index 772fe77800c..3716302ff05 100644
--- a/.github/workflows/mattpocock-skills-reviewer.md
+++ b/.github/workflows/mattpocock-skills-reviewer.md
@@ -18,7 +18,7 @@ imports:
- shared/otlp.md
- shared/pr-diff-data-fetch.md
max-daily-ai-credits: 10000
-model: claude-sonnet-4.6
+model: claude-sonnet-5
"on":
pull_request:
paths-ignore:
diff --git a/actions/setup/js/linear_add_comment.cjs b/actions/setup/js/linear_add_comment.cjs
index e7f68be7adb..e6541cd5c1e 100644
--- a/actions/setup/js/linear_add_comment.cjs
+++ b/actions/setup/js/linear_add_comment.cjs
@@ -26,10 +26,13 @@ async function main(config = {}) {
if (typeof item?.body !== "string" || !item.body.trim()) {
throw new Error(`${ERR_VALIDATION}: linear_add_comment body is required`);
}
- if (item.body.length > 65536) {
- throw new Error(`${ERR_VALIDATION}: linear_add_comment body exceeds 65536 characters`);
+ if (item.body.length > 65000) {
+ throw new Error(`${ERR_VALIDATION}: linear_add_comment body exceeds 65000 characters`);
}
const body = sanitizeContent(item.body);
+ if (!body.trim()) {
+ throw new Error(`${ERR_VALIDATION}: linear_add_comment body is empty after sanitization`);
+ }
if (isStagedMode(config)) {
logStagedPreviewInfo(`Would add a comment to Linear issue ${target}`);
diff --git a/actions/setup/js/linear_create_issue.cjs b/actions/setup/js/linear_create_issue.cjs
index 59e9502408b..fd719e57932 100644
--- a/actions/setup/js/linear_create_issue.cjs
+++ b/actions/setup/js/linear_create_issue.cjs
@@ -29,14 +29,14 @@ async function main(config = {}) {
if (typeof item?.title !== "string" || !item.title.trim()) {
throw new Error(`${ERR_VALIDATION}: linear_create_issue title is required`);
}
- if (typeof item?.body !== "string") {
+ if (typeof item?.body !== "string" || !item.body.trim()) {
throw new Error(`${ERR_VALIDATION}: linear_create_issue body is required`);
}
- if (item.title.length > 256 || item.body.length > 65536) {
+ if (item.title.length > 128 || item.body.length > 65000 || item.body.length < 20) {
throw new Error(`${ERR_VALIDATION}: linear_create_issue content exceeds the configured field limits`);
}
- const title = sanitizeTitle(item.title, "", 256);
+ const title = sanitizeTitle(item.title);
const description = sanitizeContent(item.body);
if (!title) {
throw new Error(`${ERR_VALIDATION}: linear_create_issue title is empty after sanitization`);
diff --git a/actions/setup/js/linear_graphql.cjs b/actions/setup/js/linear_graphql.cjs
index 4c1db6ac64b..fe66da94d58 100644
--- a/actions/setup/js/linear_graphql.cjs
+++ b/actions/setup/js/linear_graphql.cjs
@@ -1,7 +1,6 @@
// @ts-check
const { ERR_API, ERR_CONFIG, ERR_PARSE } = require("./error_codes.cjs");
-const { getErrorMessage } = require("./error_helpers.cjs");
const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
const LINEAR_ISSUE_PATTERN = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[A-Z][A-Z0-9]{0,15}-[1-9][0-9]*)$/i;
@@ -27,8 +26,8 @@ async function linearGraphQL(query, variables, token = process.env.GH_AW_LINEAR_
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(30_000),
});
- } catch (error) {
- throw new Error(`${ERR_API}: Linear request failed: ${redactToken(getErrorMessage(error), token)}`, { cause: error });
+ } catch {
+ throw new Error(`${ERR_API}: Linear request failed: network error`);
}
if (!response.ok) {
@@ -39,8 +38,8 @@ async function linearGraphQL(query, variables, token = process.env.GH_AW_LINEAR_
let payload;
try {
payload = await response.json();
- } catch (error) {
- throw new Error(`${ERR_PARSE}: Linear returned a malformed JSON response`, { cause: error });
+ } catch {
+ throw new Error(`${ERR_PARSE}: Linear returned a malformed JSON response`);
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
diff --git a/actions/setup/js/linear_safe_outputs.test.cjs b/actions/setup/js/linear_safe_outputs.test.cjs
index 85caa8cede8..93939df8843 100644
--- a/actions/setup/js/linear_safe_outputs.test.cjs
+++ b/actions/setup/js/linear_safe_outputs.test.cjs
@@ -32,7 +32,7 @@ describe("Linear safe outputs", () => {
it("posts fixed GraphQL documents with variables and raw API-key authorization", async () => {
fetch.mockResolvedValue(response({ data: { issueCreate: { success: true, issue: { id: "id", identifier: "ENG-1", title: "Safe title" } } } }));
const handler = await createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d" });
- await handler({ title: "Safe title", body: "Hello @user" });
+ await handler({ title: "Safe title", body: "Detailed hello to @user" });
expect(fetch).toHaveBeenCalledWith(
LINEAR_GRAPHQL_ENDPOINT,
@@ -47,7 +47,7 @@ describe("Linear safe outputs", () => {
expect(request.variables.input).toEqual({
teamId: "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
title: "Safe title",
- description: "Hello `@user`",
+ description: "Detailed hello to `@user`",
});
});
@@ -90,12 +90,12 @@ describe("Linear safe outputs", () => {
fetch.mockResolvedValueOnce(response({ data: { issueCreate: { success: false, issue: null } } }));
const handler = await createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d" });
- await expect(handler({ title: "Title", body: "Body" })).rejects.toThrow("did not return a successful issue");
+ await expect(handler({ title: "Title", body: "Body with enough detail" })).rejects.toThrow("did not return a successful issue");
});
it("rejects oversized content instead of truncating it", async () => {
const handler = await addComment({ target: "ENG-123" });
- await expect(handler({ body: "x".repeat(65537) })).rejects.toThrow("exceeds 65536 characters");
+ await expect(handler({ body: "x".repeat(65001) })).rejects.toThrow("exceeds 65000 characters");
expect(fetch).not.toHaveBeenCalled();
});
});
diff --git a/actions/setup/js/linear_update_issue.cjs b/actions/setup/js/linear_update_issue.cjs
index 7f2efe54489..a083c50b625 100644
--- a/actions/setup/js/linear_update_issue.cjs
+++ b/actions/setup/js/linear_update_issue.cjs
@@ -38,16 +38,19 @@ async function main(config = {}) {
if (item.body !== undefined && config.allow_body !== true) {
throw new Error(`${ERR_VALIDATION}: linear_update_issue body updates are not enabled`);
}
- if (item.title !== undefined && (typeof item.title !== "string" || !item.title.trim() || item.title.length > 256)) {
- throw new Error(`${ERR_VALIDATION}: linear_update_issue title must be a non-empty string of at most 256 characters`);
+ if (item.title !== undefined && (typeof item.title !== "string" || !item.title.trim() || item.title.length > 128)) {
+ throw new Error(`${ERR_VALIDATION}: linear_update_issue title must be a non-empty string of at most 128 characters`);
}
- if (item.body !== undefined && (typeof item.body !== "string" || item.body.length > 65536)) {
- throw new Error(`${ERR_VALIDATION}: linear_update_issue body must be a string of at most 65536 characters`);
+ if (item.body !== undefined && (typeof item.body !== "string" || item.body.length > 65000)) {
+ throw new Error(`${ERR_VALIDATION}: linear_update_issue body must be a string of at most 65000 characters`);
}
const input = {};
if (item.title !== undefined) {
- input.title = sanitizeTitle(item.title, "", 256);
+ input.title = sanitizeTitle(item.title);
+ if (!input.title) {
+ throw new Error(`${ERR_VALIDATION}: linear_update_issue title is empty after sanitization`);
+ }
}
if (item.body !== undefined) {
input.description = sanitizeContent(item.body);
diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json
index ce7b26b1ef3..fbe7262be10 100644
--- a/actions/setup/js/safe_outputs_tools.json
+++ b/actions/setup/js/safe_outputs_tools.json
@@ -9,13 +9,14 @@
"title": {
"type": "string",
"minLength": 1,
- "maxLength": 256,
+ "maxLength": 128,
"description": "Final Linear issue title. Standard Safe Outputs title sanitization is applied.",
"x-safe-output-sanitization": "title"
},
"body": {
"type": "string",
- "maxLength": 65536,
+ "minLength": 20,
+ "maxLength": 65000,
"description": "Linear issue description in Markdown. Standard Safe Outputs content sanitization is applied.",
"x-safe-output-sanitization": "content"
}
@@ -33,7 +34,7 @@
"body": {
"type": "string",
"minLength": 1,
- "maxLength": 65536,
+ "maxLength": 65000,
"description": "Comment body in Markdown. Standard Safe Outputs content sanitization is applied.",
"x-safe-output-sanitization": "content"
}
@@ -50,13 +51,13 @@
"title": {
"type": "string",
"minLength": 1,
- "maxLength": 256,
+ "maxLength": 128,
"description": "Replacement Linear issue title. Standard Safe Outputs title sanitization is applied.",
"x-safe-output-sanitization": "title"
},
"body": {
"type": "string",
- "maxLength": 65536,
+ "maxLength": 65000,
"description": "Replacement Linear issue description in Markdown. Standard Safe Outputs content sanitization is applied.",
"x-safe-output-sanitization": "content"
}
diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md
index 115301b6216..e04acefae02 100644
--- a/docs/src/content/docs/specs/safe-outputs-specification.md
+++ b/docs/src/content/docs/specs/safe-outputs-specification.md
@@ -2757,7 +2757,7 @@ This section provides complete definitions for all remaining safe output types.
**MCP Tool**: `linear_create_issue`
-The MCP input object MUST require `title` and `body`, MUST reject additional properties, and MUST limit them to 256 and 65,536 characters respectively. The trusted team UUID and credential MUST NOT be MCP inputs.
+The MCP input object MUST require `title` and `body`, MUST reject additional properties, and MUST limit them to 128 and 65,000 characters respectively. The body MUST contain at least 20 characters. The trusted team UUID and credential MUST NOT be MCP inputs.
**Operational Semantics**:
@@ -2782,7 +2782,7 @@ The configured team ID MUST be a canonical UUID. Input exceeding a configured li
**MCP Tool**: `linear_add_comment`
-The MCP input object MUST require only `body`, MUST reject additional properties, and MUST limit the body to 65,536 characters. The target and credential MUST NOT be MCP inputs.
+The MCP input object MUST require only `body`, MUST reject additional properties, and MUST limit the body to 65,000 characters. The target and credential MUST NOT be MCP inputs.
The processor MUST use the fixed `commentCreate(input: CommentCreateInput!)` GraphQL document and pass the configured target as `issueId` through variables. The body MUST undergo standard Safe Outputs content sanitization. HTTP, parsing, GraphQL, unsuccessful-payload, and staged-mode behavior MUST match `linear_create_issue`.
diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json
index ce7b26b1ef3..fbe7262be10 100644
--- a/pkg/workflow/js/safe_outputs_tools.json
+++ b/pkg/workflow/js/safe_outputs_tools.json
@@ -9,13 +9,14 @@
"title": {
"type": "string",
"minLength": 1,
- "maxLength": 256,
+ "maxLength": 128,
"description": "Final Linear issue title. Standard Safe Outputs title sanitization is applied.",
"x-safe-output-sanitization": "title"
},
"body": {
"type": "string",
- "maxLength": 65536,
+ "minLength": 20,
+ "maxLength": 65000,
"description": "Linear issue description in Markdown. Standard Safe Outputs content sanitization is applied.",
"x-safe-output-sanitization": "content"
}
@@ -33,7 +34,7 @@
"body": {
"type": "string",
"minLength": 1,
- "maxLength": 65536,
+ "maxLength": 65000,
"description": "Comment body in Markdown. Standard Safe Outputs content sanitization is applied.",
"x-safe-output-sanitization": "content"
}
@@ -50,13 +51,13 @@
"title": {
"type": "string",
"minLength": 1,
- "maxLength": 256,
+ "maxLength": 128,
"description": "Replacement Linear issue title. Standard Safe Outputs title sanitization is applied.",
"x-safe-output-sanitization": "title"
},
"body": {
"type": "string",
- "maxLength": 65536,
+ "maxLength": 65000,
"description": "Replacement Linear issue description in Markdown. Standard Safe Outputs content sanitization is applied.",
"x-safe-output-sanitization": "content"
}
diff --git a/pkg/workflow/linear_safe_outputs.go b/pkg/workflow/linear_safe_outputs.go
index a419c412900..a5a1d0595bf 100644
--- a/pkg/workflow/linear_safe_outputs.go
+++ b/pkg/workflow/linear_safe_outputs.go
@@ -24,6 +24,27 @@ type LinearUpdateIssueConfig struct {
Body *bool `yaml:"body,omitempty"`
}
+func (c *SafeOutputsConfig) linearCreateIssueMax() *string {
+ if c.LinearCreateIssue == nil {
+ return nil
+ }
+ return c.LinearCreateIssue.Max
+}
+
+func (c *SafeOutputsConfig) linearAddCommentMax() *string {
+ if c.LinearAddComment == nil {
+ return nil
+ }
+ return c.LinearAddComment.Max
+}
+
+func (c *SafeOutputsConfig) linearUpdateIssueMax() *string {
+ if c.LinearUpdateIssue == nil {
+ return nil
+ }
+ return c.LinearUpdateIssue.Max
+}
+
func preprocessLinearBaseConfig(outputMap map[string]any, key string) {
configData, _ := outputMap[key].(map[string]any)
if configData == nil {
@@ -90,3 +111,17 @@ func injectLinearTokenEnv(steps []string, config *SafeOutputsConfig) []string {
}
return steps
}
+
+func defaultReportIncompleteCreateIssue(config *SafeOutputsConfig) string {
+ if !hasLinearSafeOutputs(config) {
+ return "true"
+ }
+ withoutLinear := *config
+ withoutLinear.LinearCreateIssue = nil
+ withoutLinear.LinearAddComment = nil
+ withoutLinear.LinearUpdateIssue = nil
+ if hasNonBuiltinSafeOutputsEnabled(&withoutLinear) {
+ return "true"
+ }
+ return "false"
+}
diff --git a/pkg/workflow/linear_safe_outputs_test.go b/pkg/workflow/linear_safe_outputs_test.go
index af45f3fb055..8d0ab7e4582 100644
--- a/pkg/workflow/linear_safe_outputs_test.go
+++ b/pkg/workflow/linear_safe_outputs_test.go
@@ -81,6 +81,33 @@ func TestLinearSafeOutputsNeedNoGitHubWritePermissions(t *testing.T) {
assert.Empty(t, permissions.permissions)
}
+func TestLinearSafeOutputsPreventDefaultGitHubIssueInjection(t *testing.T) {
+ data := &WorkflowData{
+ WorkflowID: "linear",
+ SafeOutputs: &SafeOutputsConfig{
+ LinearAddComment: &LinearTargetConfig{Target: "ENG-123"},
+ },
+ }
+
+ applyDefaultCreateIssue(data)
+ assert.Nil(t, data.SafeOutputs.CreateIssues)
+ assert.True(t, HasSafeOutputsEnabled(data.SafeOutputs))
+}
+
+func TestLinearOnlyDefaultsIncompleteReportingWithoutGitHubIssue(t *testing.T) {
+ config := NewCompiler().extractSafeOutputsConfig(map[string]any{
+ "safe-outputs": map[string]any{
+ "linear-token": "${{ secrets.LINEAR_API_KEY }}",
+ "linear-add-comment": map[string]any{"target": "ENG-123"},
+ },
+ })
+
+ require.NotNil(t, config.ReportIncomplete)
+ require.NotNil(t, config.ReportIncomplete.CreateIssue)
+ assert.Equal(t, "false", *config.ReportIncomplete.CreateIssue)
+ assert.Empty(t, computePermissionsForSafeOutputs(config, false).permissions)
+}
+
func TestLinearTokenOnlyAddedToTrustedProcessingStep(t *testing.T) {
data := &WorkflowData{SafeOutputs: &SafeOutputsConfig{
LinearToken: "${{ secrets.LINEAR_API_KEY }}",
diff --git a/pkg/workflow/safe_outputs_config_extraction.go b/pkg/workflow/safe_outputs_config_extraction.go
index 0206c417111..9b586764c75 100644
--- a/pkg/workflow/safe_outputs_config_extraction.go
+++ b/pkg/workflow/safe_outputs_config_extraction.go
@@ -389,7 +389,7 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut
// Enable report-incomplete by default if safe-outputs exists and it wasn't explicitly disabled.
// This ensures agents always have a first-class channel to signal task incompletion.
if _, exists := outputMap["report-incomplete"]; !exists {
- trueVal := "true"
+ trueVal := defaultReportIncompleteCreateIssue(config)
config.ReportIncomplete = &ReportIncompleteConfig{
CreateIssue: &trueVal,
TitlePrefix: "",
diff --git a/pkg/workflow/safe_outputs_handler_registry_test.go b/pkg/workflow/safe_outputs_handler_registry_test.go
index e0293d0a4ad..7d5750ace1f 100644
--- a/pkg/workflow/safe_outputs_handler_registry_test.go
+++ b/pkg/workflow/safe_outputs_handler_registry_test.go
@@ -21,6 +21,7 @@ func TestHandlerRegistryDomainComposition(t *testing.T) {
{name: "commentHandlerRegistry", registry: commentHandlerRegistry, wantKeys: []string{"add_comment", "hide_comment"}},
{name: "releaseHandlerRegistry", registry: releaseHandlerRegistry, wantKeys: []string{"update_release"}},
{name: "diagnosticHandlerRegistry", registry: diagnosticHandlerRegistry, wantKeys: []string{"missing_tool", "missing_data", "noop", "report_incomplete", "create_report_incomplete_issue"}},
+ {name: "linearHandlerRegistry", registry: linearHandlerRegistry, wantKeys: []string{"linear_create_issue", "linear_add_comment", "linear_update_issue"}},
}
wantAll := map[string]struct{}{}
@@ -106,6 +107,9 @@ func TestHandlerRegistryBuilders(t *testing.T) {
{name: "noop", cfg: &SafeOutputsConfig{NoOp: &NoOpConfig{}}},
{name: "report_incomplete", cfg: &SafeOutputsConfig{ReportIncomplete: &ReportIncompleteConfig{}}},
{name: "create_report_incomplete_issue", cfg: &SafeOutputsConfig{ReportIncomplete: &ReportIncompleteConfig{CreateIssue: strPtr("true")}}},
+ {name: "linear_create_issue", cfg: &SafeOutputsConfig{LinearCreateIssue: &LinearCreateIssueConfig{}}},
+ {name: "linear_add_comment", cfg: &SafeOutputsConfig{LinearAddComment: &LinearTargetConfig{}}},
+ {name: "linear_update_issue", cfg: &SafeOutputsConfig{LinearUpdateIssue: &LinearUpdateIssueConfig{}}},
}
for _, tt := range tests {
diff --git a/pkg/workflow/safe_outputs_max_validation.go b/pkg/workflow/safe_outputs_max_validation.go
index 55079bdd179..221bfec427e 100644
--- a/pkg/workflow/safe_outputs_max_validation.go
+++ b/pkg/workflow/safe_outputs_max_validation.go
@@ -55,6 +55,8 @@ func checkMaxField(toolName string, maxPtr *string) error {
// This function uses direct struct field access instead of reflection for performance;
// it is on the hot path and called on every compilation. The field ordering matches
// the sorted safeOutputFieldMapping keys for deterministic error reporting.
+//
+//nolint:largefunc // Direct field access keeps this hot-path validation allocation-free.
func validateSafeOutputsMax(config *SafeOutputsConfig) error {
if config == nil {
return nil
@@ -186,10 +188,14 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error {
return err
}
}
+ if err := validateLinearSafeOutputsMax(config); err != nil {
+ return err
+ }
if config.MarkPullRequestAsReadyForReview != nil {
if err := checkMaxField("mark_pull_request_as_ready_for_review", config.MarkPullRequestAsReadyForReview.Max); err != nil {
return err
}
+
}
if config.ApproveWorkflowRun != nil {
if err := checkMaxField("approve_workflow_run", config.ApproveWorkflowRun.Max); err != nil {
@@ -331,3 +337,20 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error {
safeOutputsMaxValidationLog.Print("Safe-outputs max fields validation passed")
return nil
}
+
+func validateLinearSafeOutputsMax(config *SafeOutputsConfig) error {
+ handlers := []struct {
+ name string
+ max *string
+ }{
+ {name: "linear_add_comment", max: config.linearAddCommentMax()},
+ {name: "linear_create_issue", max: config.linearCreateIssueMax()},
+ {name: "linear_update_issue", max: config.linearUpdateIssueMax()},
+ }
+ for _, handler := range handlers {
+ if err := checkMaxField(handler.name, handler.max); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/pkg/workflow/safe_outputs_state.go b/pkg/workflow/safe_outputs_state.go
index ea55a7806b3..d187ff0335e 100644
--- a/pkg/workflow/safe_outputs_state.go
+++ b/pkg/workflow/safe_outputs_state.go
@@ -87,7 +87,8 @@ func hasAnySafeOutputEnabled(safeOutputs *SafeOutputsConfig) bool {
safeOutputs.MissingData != nil ||
safeOutputs.SetIssueType != nil ||
safeOutputs.SetIssueField != nil ||
- safeOutputs.NoOp != nil
+ safeOutputs.NoOp != nil ||
+ hasLinearSafeOutputs(safeOutputs)
}
// The builtin types (noop, missing-data, missing-tool) are excluded from this check
@@ -150,7 +151,14 @@ func hasNonBuiltinSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool {
safeOutputs.DispatchRepository != nil ||
safeOutputs.CallWorkflow != nil ||
safeOutputs.SetIssueType != nil ||
- safeOutputs.SetIssueField != nil // non-builtin safe output field
+ safeOutputs.SetIssueField != nil || // non-builtin safe output field
+ hasLinearSafeOutputs(safeOutputs)
+}
+
+func hasLinearSafeOutputs(safeOutputs *SafeOutputsConfig) bool {
+ return safeOutputs.LinearCreateIssue != nil ||
+ safeOutputs.LinearAddComment != nil ||
+ safeOutputs.LinearUpdateIssue != nil
}
// HasSafeOutputsEnabled checks if any safe-outputs are enabled
diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go
index 78e0bcfb7da..70128d9a276 100644
--- a/pkg/workflow/safe_outputs_validation_config.go
+++ b/pkg/workflow/safe_outputs_validation_config.go
@@ -60,6 +60,26 @@ const (
// ValidationConfig contains all safe output type validation rules
// This is the single source of truth for validation rules
var ValidationConfig = map[string]TypeValidationConfig{
+ "linear_create_issue": {
+ DefaultMax: 1,
+ Fields: map[string]FieldValidation{
+ "title": {Required: true, Type: "string", Sanitize: true, MaxLength: 128},
+ "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength, MinLength: MinIssueBodyLength},
+ },
+ },
+ "linear_add_comment": {
+ DefaultMax: 1,
+ Fields: map[string]FieldValidation{
+ "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength},
+ },
+ },
+ "linear_update_issue": {
+ DefaultMax: 1,
+ Fields: map[string]FieldValidation{
+ "title": {Type: "string", Sanitize: true, MaxLength: 128},
+ "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength},
+ },
+ },
"create_issue": {
DefaultMax: 1,
Fields: map[string]FieldValidation{
@@ -564,6 +584,8 @@ var validationConfigJSONCache sync.Map // key: string → value: string
// GetValidationConfigJSONWithDataSchema behaves like GetValidationConfigJSONWithDataSchema and additionally
// injects a normalized data schema into body-bearing safe-output types.
+//
+//nolint:largefunc // Validation schema assembly remains centralized for deterministic caching.
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)
From 14e9729a51ddbe6181415012d0f119c8068c41a2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Sep 2026 01:56:51 +0000
Subject: [PATCH 3/7] Fix Linear tool schema typing
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
actions/setup/js/generate_safe_outputs_tools.cjs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/actions/setup/js/generate_safe_outputs_tools.cjs b/actions/setup/js/generate_safe_outputs_tools.cjs
index c6119d3e76f..71cebaa7c72 100644
--- a/actions/setup/js/generate_safe_outputs_tools.cjs
+++ b/actions/setup/js/generate_safe_outputs_tools.cjs
@@ -387,10 +387,10 @@ async function main() {
const linearUpdateConfig = config.linear_update_issue;
const properties = enhancedTool.inputSchema?.properties;
if (properties && linearUpdateConfig && typeof linearUpdateConfig === "object") {
- if (linearUpdateConfig.allow_title !== true) {
+ if (!("allow_title" in linearUpdateConfig) || linearUpdateConfig.allow_title !== true) {
delete properties.title;
}
- if (linearUpdateConfig.allow_body !== true) {
+ if (!("allow_body" in linearUpdateConfig) || linearUpdateConfig.allow_body !== true) {
delete properties.body;
}
enhancedTool.inputSchema.anyOf = Object.keys(properties).map(field => ({ required: [field] }));
From 14a3396fd7150a29891581f9369174d73cd3d3f3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Sep 2026 02:28:45 +0000
Subject: [PATCH 4/7] Keep Linear token on processor step
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
pkg/workflow/compiler_safe_outputs_job.go | 2 +-
pkg/workflow/linear_safe_outputs.go | 9 +++++++--
pkg/workflow/linear_safe_outputs_test.go | 9 ++++++++-
3 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go
index 5866ae2d51b..594037cd8c6 100644
--- a/pkg/workflow/compiler_safe_outputs_job.go
+++ b/pkg/workflow/compiler_safe_outputs_job.go
@@ -406,7 +406,7 @@ func (c *Compiler) appendHandlerManagerStep(data *WorkflowData, state *safeOutpu
if err != nil {
return err
}
- handlerManagerSteps = injectLinearTokenEnv(handlerManagerSteps, data.SafeOutputs)
+ handlerManagerSteps = injectLinearTokenIntoProcessorStep(handlerManagerSteps, data.SafeOutputs)
state.steps = append(state.steps, handlerManagerSteps...)
state.safeOutputStepNames = append(state.safeOutputStepNames, "process_safe_outputs")
addHandlerManagerOutputs(data, state.outputs)
diff --git a/pkg/workflow/linear_safe_outputs.go b/pkg/workflow/linear_safe_outputs.go
index a5a1d0595bf..999a125f299 100644
--- a/pkg/workflow/linear_safe_outputs.go
+++ b/pkg/workflow/linear_safe_outputs.go
@@ -97,14 +97,19 @@ func (c *Compiler) parseLinearUpdateIssueConfig(outputMap map[string]any) *Linea
return parseLinearConfig[LinearUpdateIssueConfig](outputMap, "linear-update-issue")
}
-func injectLinearTokenEnv(steps []string, config *SafeOutputsConfig) []string {
+func injectLinearTokenIntoProcessorStep(steps []string, config *SafeOutputsConfig) []string {
if config == nil || config.LinearToken == "" ||
(config.LinearCreateIssue == nil && config.LinearAddComment == nil && config.LinearUpdateIssue == nil) {
return steps
}
+ processStepFound := false
for index, step := range steps {
- if step == " env:\n" {
+ if step == " - name: Process Safe Outputs\n" {
+ processStepFound = true
+ continue
+ }
+ if processStepFound && step == " env:\n" {
tokenEnv := fmt.Sprintf(" GH_AW_LINEAR_TOKEN: %s\n", config.LinearToken)
return append(steps[:index+1], append([]string{tokenEnv}, steps[index+1:]...)...)
}
diff --git a/pkg/workflow/linear_safe_outputs_test.go b/pkg/workflow/linear_safe_outputs_test.go
index 8d0ab7e4582..d0b99f6aec3 100644
--- a/pkg/workflow/linear_safe_outputs_test.go
+++ b/pkg/workflow/linear_safe_outputs_test.go
@@ -112,13 +112,20 @@ func TestLinearTokenOnlyAddedToTrustedProcessingStep(t *testing.T) {
data := &WorkflowData{SafeOutputs: &SafeOutputsConfig{
LinearToken: "${{ secrets.LINEAR_API_KEY }}",
LinearCreateIssue: &LinearCreateIssueConfig{TeamID: "9cfb482a-81e3-4154-b5b9-2c805e70a02d"},
+ GitHubApp: &GitHubAppConfig{
+ AppID: "${{ vars.APP_ID }}",
+ PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}",
+ },
}}
compiler := NewCompiler()
steps, err := compiler.buildHandlerManagerStep(data)
require.NoError(t, err)
- steps = injectLinearTokenEnv(steps, data.SafeOutputs)
+ steps = injectLinearTokenIntoProcessorStep(steps, data.SafeOutputs)
rendered := strings.Join(steps, "")
assert.Contains(t, rendered, "GH_AW_LINEAR_TOKEN: ${{ secrets.LINEAR_API_KEY }}")
assert.NotContains(t, rendered, "linear-token")
+ processStep := rendered[strings.Index(rendered, "- name: Process Safe Outputs"):]
+ assert.Contains(t, processStep, "env:\n GH_AW_LINEAR_TOKEN:")
+ assert.NotContains(t, rendered[:strings.Index(rendered, "- name: Process Safe Outputs")], "GH_AW_LINEAR_TOKEN")
}
From 83f952b0c00157d3633d6fc33d327745370ca1cb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Sep 2026 02:54:18 +0000
Subject: [PATCH 5/7] Mark Linear safe outputs experimental
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../content/docs/reference/safe-outputs.md | 10 +++--
.../docs/specs/safe-outputs-specification.md | 6 +++
pkg/parser/schemas/main_workflow_schema.json | 6 +--
pkg/workflow/compiler_validators.go | 1 +
..._safe_outputs_experimental_warning_test.go | 41 +++++++++++++++++++
pkg/workflow/safe_outputs_state.go | 7 ++--
6 files changed, 62 insertions(+), 9 deletions(-)
create mode 100644 pkg/workflow/linear_safe_outputs_experimental_warning_test.go
diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md
index b616b5a2c0d..9500b140a8b 100644
--- a/docs/src/content/docs/reference/safe-outputs.md
+++ b/docs/src/content/docs/reference/safe-outputs.md
@@ -93,12 +93,16 @@ The tables below summarize the built-in safe output handlers. `noop`, `missing-t
| Output | Key | Description |
|--------|-----|-------------|
-| [Create Linear Issue](#linear-safe-outputs) | `linear-create-issue` | Create an issue in a configured Linear team (max: 1) |
-| [Add Linear Comment](#linear-safe-outputs) | `linear-add-comment` | Comment on a configured Linear issue (max: 1) |
-| [Update Linear Issue](#linear-safe-outputs) | `linear-update-issue` | Update enabled fields on a configured Linear issue (max: 1) |
+| [Create Linear Issue](#linear-safe-outputs) | `linear-create-issue` | Create an issue in a configured Linear team (max: 1, experimental) |
+| [Add Linear Comment](#linear-safe-outputs) | `linear-add-comment` | Comment on a configured Linear issue (max: 1, experimental) |
+| [Update Linear Issue](#linear-safe-outputs) | `linear-update-issue` | Update enabled fields on a configured Linear issue (max: 1, experimental) |
#### Linear Safe Outputs
+:::caution[Experimental]
+Linear Safe Outputs are experimental. Compiling a workflow that enables any Linear Safe Output emits `Using experimental feature: Linear safe outputs`.
+:::
+
Linear Safe Outputs use Linear's public GraphQL API from the isolated `safe_outputs` job. Configure a personal Linear API key through a secret expression. The credential is not available to the agent.
```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 e04acefae02..2a40e98f0e3 100644
--- a/docs/src/content/docs/specs/safe-outputs-specification.md
+++ b/docs/src/content/docs/specs/safe-outputs-specification.md
@@ -2748,6 +2748,8 @@ This section provides complete definitions for all remaining safe output types.
**Purpose**: Create an issue in one trusted Linear team using Linear's public GraphQL API.
+**Experimental**: Yes. Compiling a workflow with any Linear Safe Output emits: `Using experimental feature: Linear safe outputs`.
+
**Configuration**:
- `linear-token`: REQUIRED trusted secret expression containing a Linear personal API key
@@ -2773,6 +2775,8 @@ The configured team ID MUST be a canonical UUID. Input exceeding a configured li
**Purpose**: Add a comment to one trusted Linear issue.
+**Experimental**: Yes.
+
**Configuration**:
- `linear-token`: REQUIRED trusted secret expression containing a Linear personal API key
@@ -2790,6 +2794,8 @@ The processor MUST use the fixed `commentCreate(input: CommentCreateInput!)` Gra
**Purpose**: Replace explicitly enabled basic fields on one trusted Linear issue.
+**Experimental**: Yes.
+
**Configuration**:
- `linear-token`: REQUIRED trusted secret expression containing a Linear personal API key
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index fd1b5a67952..71b627e9640 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -7317,7 +7317,7 @@
},
"linear-create-issue": {
"type": "object",
- "description": "Create Linear issues through the isolated safe_outputs job.",
+ "description": "Experimental. Create Linear issues through the isolated safe_outputs job.",
"properties": {
"team-id": {
"type": "string",
@@ -7344,7 +7344,7 @@
},
"linear-add-comment": {
"type": "object",
- "description": "Add comments to one trusted Linear issue through the isolated safe_outputs job.",
+ "description": "Experimental. Add comments to one trusted Linear issue through the isolated safe_outputs job.",
"properties": {
"target": {
"$ref": "#/$defs/linear_issue_identifier",
@@ -7370,7 +7370,7 @@
},
"linear-update-issue": {
"type": "object",
- "description": "Update explicitly enabled fields on one trusted Linear issue through the isolated safe_outputs job.",
+ "description": "Experimental. Update explicitly enabled fields on one trusted Linear issue through the isolated safe_outputs job.",
"properties": {
"target": {
"$ref": "#/$defs/linear_issue_identifier",
diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go
index d5811e40e8c..42e7df1de4c 100644
--- a/pkg/workflow/compiler_validators.go
+++ b/pkg/workflow/compiler_validators.go
@@ -432,6 +432,7 @@ func (c *Compiler) emitExperimentalFeatureWarningsTo(workflowData *WorkflowData,
{enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ApproveWorkflowRun != nil, message: "Using experimental feature: approve-workflow-run"},
{enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ReplaceLabel != nil, message: "Using experimental feature: replace-label"},
{enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UploadCodeCoverage != nil, message: "Using experimental feature: upload-code-coverage"},
+ {enabled: hasLinearSafeOutputs(workflowData.SafeOutputs), message: "Using experimental feature: Linear safe outputs"},
{enabled: detectionConfigured && isFeatureEnabled(constants.GHAWDetectionFeatureFlag, workflowData), message: "Using experimental feature: gh-aw-detection"},
{enabled: len(workflowData.LSP) > 0, message: "Using experimental feature: lsp"},
{enabled: len(workflowData.Plugins) > 0, message: "Using experimental feature: plugins"},
diff --git a/pkg/workflow/linear_safe_outputs_experimental_warning_test.go b/pkg/workflow/linear_safe_outputs_experimental_warning_test.go
new file mode 100644
index 00000000000..6ad6528ec23
--- /dev/null
+++ b/pkg/workflow/linear_safe_outputs_experimental_warning_test.go
@@ -0,0 +1,41 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLinearSafeOutputsExperimentalWarning(t *testing.T) {
+ const warning = "Using experimental feature: Linear safe outputs"
+
+ tests := []struct {
+ name string
+ safeOutputs *SafeOutputsConfig
+ expect bool
+ }{
+ {name: "create issue", safeOutputs: &SafeOutputsConfig{LinearCreateIssue: &LinearCreateIssueConfig{}}, expect: true},
+ {name: "add comment", safeOutputs: &SafeOutputsConfig{LinearAddComment: &LinearTargetConfig{}}, expect: true},
+ {name: "update issue", safeOutputs: &SafeOutputsConfig{LinearUpdateIssue: &LinearUpdateIssueConfig{}}, expect: true},
+ {name: "non-Linear output", safeOutputs: &SafeOutputsConfig{CreateIssues: &CreateIssuesConfig{}}},
+ {name: "no safe outputs"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ compiler := NewCompiler()
+ var output bytes.Buffer
+ compiler.emitExperimentalFeatureWarningsTo(&WorkflowData{SafeOutputs: tt.safeOutputs}, &output)
+
+ if tt.expect {
+ assert.Contains(t, output.String(), warning)
+ assert.Equal(t, 1, compiler.GetWarningCount())
+ } else {
+ assert.NotContains(t, output.String(), warning)
+ }
+ })
+ }
+}
diff --git a/pkg/workflow/safe_outputs_state.go b/pkg/workflow/safe_outputs_state.go
index d187ff0335e..51039b8b6f4 100644
--- a/pkg/workflow/safe_outputs_state.go
+++ b/pkg/workflow/safe_outputs_state.go
@@ -156,9 +156,10 @@ func hasNonBuiltinSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool {
}
func hasLinearSafeOutputs(safeOutputs *SafeOutputsConfig) bool {
- return safeOutputs.LinearCreateIssue != nil ||
- safeOutputs.LinearAddComment != nil ||
- safeOutputs.LinearUpdateIssue != nil
+ return safeOutputs != nil &&
+ (safeOutputs.LinearCreateIssue != nil ||
+ safeOutputs.LinearAddComment != nil ||
+ safeOutputs.LinearUpdateIssue != nil)
}
// HasSafeOutputsEnabled checks if any safe-outputs are enabled
From 7c2468f3ec59d4870572122f5d335e15dea0a17f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Sep 2026 03:49:12 +0000
Subject: [PATCH 6/7] Fix CI failures: model name test expectations and Linear
MCP schema issues
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
---
actions/setup/js/safe_outputs_tools.json | 5 ++---
pkg/workflow/js/safe_outputs_tools.json | 5 ++---
pkg/workflow/prompts_test.go | 6 +++---
pkg/workflow/semantic_function_refactor_workflow_test.go | 2 +-
4 files changed, 8 insertions(+), 10 deletions(-)
diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json
index fbe7262be10..b5c18a131f8 100644
--- a/actions/setup/js/safe_outputs_tools.json
+++ b/actions/setup/js/safe_outputs_tools.json
@@ -1,7 +1,7 @@
[
{
"name": "linear_create_issue",
- "description": "Create an issue in the Linear team fixed by safe-outputs.linear-create-issue.team-id. The Linear credential and team ID are trusted workflow configuration and are not agent inputs.",
+ "description": "Use this to create an issue in the Linear team fixed by safe-outputs.linear-create-issue.team-id. The Linear credential and team ID are trusted workflow configuration and are not agent inputs.",
"inputSchema": {
"type": "object",
"required": ["title", "body"],
@@ -44,7 +44,7 @@
},
{
"name": "linear_update_issue",
- "description": "Update explicitly enabled fields on the Linear issue fixed by safe-outputs.linear-update-issue.target. The target and Linear credential are trusted workflow configuration and are not agent inputs.",
+ "description": "Use this to update explicitly enabled fields on the Linear issue fixed by safe-outputs.linear-update-issue.target. The target and Linear credential are trusted workflow configuration and are not agent inputs. Provide at least one of title or body.",
"inputSchema": {
"type": "object",
"properties": {
@@ -62,7 +62,6 @@
"x-safe-output-sanitization": "content"
}
},
- "anyOf": [{ "required": ["title"] }, { "required": ["body"] }],
"additionalProperties": false
}
},
diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json
index fbe7262be10..b5c18a131f8 100644
--- a/pkg/workflow/js/safe_outputs_tools.json
+++ b/pkg/workflow/js/safe_outputs_tools.json
@@ -1,7 +1,7 @@
[
{
"name": "linear_create_issue",
- "description": "Create an issue in the Linear team fixed by safe-outputs.linear-create-issue.team-id. The Linear credential and team ID are trusted workflow configuration and are not agent inputs.",
+ "description": "Use this to create an issue in the Linear team fixed by safe-outputs.linear-create-issue.team-id. The Linear credential and team ID are trusted workflow configuration and are not agent inputs.",
"inputSchema": {
"type": "object",
"required": ["title", "body"],
@@ -44,7 +44,7 @@
},
{
"name": "linear_update_issue",
- "description": "Update explicitly enabled fields on the Linear issue fixed by safe-outputs.linear-update-issue.target. The target and Linear credential are trusted workflow configuration and are not agent inputs.",
+ "description": "Use this to update explicitly enabled fields on the Linear issue fixed by safe-outputs.linear-update-issue.target. The target and Linear credential are trusted workflow configuration and are not agent inputs. Provide at least one of title or body.",
"inputSchema": {
"type": "object",
"properties": {
@@ -62,7 +62,6 @@
"x-safe-output-sanitization": "content"
}
},
- "anyOf": [{ "required": ["title"] }, { "required": ["body"] }],
"additionalProperties": false
}
},
diff --git a/pkg/workflow/prompts_test.go b/pkg/workflow/prompts_test.go
index 80e4c925a9b..3a6eecbe4fc 100644
--- a/pkg/workflow/prompts_test.go
+++ b/pkg/workflow/prompts_test.go
@@ -337,12 +337,12 @@ func TestDailyCavemanOptimizerUsesConcreteClaudeModelsForExperiment(t *testing.T
t.Fatalf("Expected exactly 2 concrete Claude variants, got %#v", variants)
}
expected := map[any]bool{
- "claude-sonnet-4.6": true,
- "claude-haiku-4.5": true,
+ "claude-sonnet-5": true,
+ "claude-haiku-4.5": true,
}
for _, variant := range variants {
if !expected[variant] {
- t.Fatalf("Expected concrete Claude variants [claude-sonnet-4.6, claude-haiku-4.5], got %#v", variants)
+ t.Fatalf("Expected concrete Claude variants [claude-sonnet-5, claude-haiku-4.5], got %#v", variants)
}
}
}
diff --git a/pkg/workflow/semantic_function_refactor_workflow_test.go b/pkg/workflow/semantic_function_refactor_workflow_test.go
index 956a6400121..d07e7612709 100644
--- a/pkg/workflow/semantic_function_refactor_workflow_test.go
+++ b/pkg/workflow/semantic_function_refactor_workflow_test.go
@@ -70,7 +70,7 @@ func TestSemanticFunctionRefactorWorkflowCostGuardrails(t *testing.T) {
`17 2 * * *`,
`GH_AW_MAX_DAILY_AI_CREDITS: "300"`,
`"maxAiCredits":300`,
- `claude-sonnet-4.6`,
+ `claude-sonnet-5`,
`name: Precompute semantic refactor slice`,
`/tmp/gh-aw/agent/semantic-function-refactor/targets.txt`,
`/tmp/gh-aw/agent/semantic-function-refactor/go-files.txt`,
From d4756f10a39b016e998fba092566f480378371e6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Sep 2026 04:04:08 +0000
Subject: [PATCH 7/7] Fix Linear safe-outputs credential separation, import
merge, and validation review issues
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
---
.changeset/minor-add-linear-safe-outputs.md | 5 +
actions/setup/js/linear_create_issue.cjs | 2 +-
.../setup/js/safe_output_type_validator.cjs | 13 ++
.../js/safe_output_type_validator.test.cjs | 37 +++++
pkg/workflow/compiler_safe_outputs_job.go | 31 +++--
pkg/workflow/imports.go | 14 ++
pkg/workflow/linear_safe_outputs_test.go | 43 ++++++
pkg/workflow/missing_issue_reporting.go | 1 +
.../safe_outputs_config_extraction.go | 1 +
pkg/workflow/safe_outputs_import_test.go | 127 ++++++++++++++++++
.../safe_outputs_validation_config.go | 15 ++-
11 files changed, 269 insertions(+), 20 deletions(-)
create mode 100644 .changeset/minor-add-linear-safe-outputs.md
diff --git a/.changeset/minor-add-linear-safe-outputs.md b/.changeset/minor-add-linear-safe-outputs.md
new file mode 100644
index 00000000000..0a08ec42be7
--- /dev/null
+++ b/.changeset/minor-add-linear-safe-outputs.md
@@ -0,0 +1,5 @@
+---
+"gh-aw": minor
+---
+
+Add experimental native Linear safe outputs: `linear-create-issue`, `linear-add-comment`, and `linear-update-issue`. The privileged `safe_outputs` job sanitizes agent-emitted operations and executes fixed GraphQL mutations against a configured Linear team/issue target, keeping the `linear-token` credential out of agent jobs, MCP schemas, and artifacts.
diff --git a/actions/setup/js/linear_create_issue.cjs b/actions/setup/js/linear_create_issue.cjs
index fd719e57932..0e140b5dd90 100644
--- a/actions/setup/js/linear_create_issue.cjs
+++ b/actions/setup/js/linear_create_issue.cjs
@@ -33,7 +33,7 @@ async function main(config = {}) {
throw new Error(`${ERR_VALIDATION}: linear_create_issue body is required`);
}
if (item.title.length > 128 || item.body.length > 65000 || item.body.length < 20) {
- throw new Error(`${ERR_VALIDATION}: linear_create_issue content exceeds the configured field limits`);
+ throw new Error(`${ERR_VALIDATION}: linear_create_issue content is outside the configured field limits`);
}
const title = sanitizeTitle(item.title);
diff --git a/actions/setup/js/safe_output_type_validator.cjs b/actions/setup/js/safe_output_type_validator.cjs
index 40eef4da145..57af50a8a75 100644
--- a/actions/setup/js/safe_output_type_validator.cjs
+++ b/actions/setup/js/safe_output_type_validator.cjs
@@ -218,6 +218,10 @@ function validateIssueIntentLabels(value, lineNum, itemType, fieldName, options)
* @property {number} [itemMaxLength] - For arrays, max length per item
* @property {string} [pattern] - Regex pattern the value must match
* @property {string} [patternError] - Error message for pattern mismatch
+ * @property {boolean} [rejectIfOversized] - When true, reject the field outright (instead of
+ * silently truncating via sanitizeContent) if the raw pre-sanitization value exceeds
+ * maxLength. Used for external-system fields (e.g. Linear) where truncation could turn an
+ * oversized/placeholder value into a deceptively short but "valid" operation.
* @property {boolean} [x-strip-on-error] - When true, strip the field on validation failure
* instead of rejecting the whole item. Bracket access only (validation["x-strip-on-error"])
* because the key contains hyphens. Used for optional enrichment fields like confidence and rationale.
@@ -560,6 +564,15 @@ function validateField(value, fieldName, validation, itemType, lineNum, options)
return { isValid: true, normalizedValue: normalizedResult };
}
+ // Reject outright (instead of silently truncating) when configured, so an oversized
+ // raw value cannot be converted into a deceptively short but "valid" operation.
+ if (validation.rejectIfOversized && validation.maxLength && value.length > validation.maxLength) {
+ return {
+ isValid: false,
+ error: `Line ${lineNum}: ${itemType} '${fieldName}' exceeds maximum length (${validation.maxLength} characters)`,
+ };
+ }
+
// Handle sanitization
let finalValue = value;
if (validation.sanitize) {
diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs
index 27226776945..e00298e95e8 100644
--- a/actions/setup/js/safe_output_type_validator.test.cjs
+++ b/actions/setup/js/safe_output_type_validator.test.cjs
@@ -21,6 +21,13 @@ const SAMPLE_VALIDATION_CONFIG = {
temporary_id: { type: "string" },
},
},
+ linear_create_issue: {
+ defaultMax: 1,
+ fields: {
+ title: { required: true, type: "string", sanitize: true, maxLength: 128, rejectIfOversized: true },
+ body: { required: true, type: "string", sanitize: true, maxLength: 65000, minLength: 20, rejectIfOversized: true },
+ },
+ },
add_comment: {
defaultMax: 1,
dataEnabled: true,
@@ -1328,6 +1335,36 @@ describe("safe_output_type_validator", () => {
});
});
+ describe("rejectIfOversized validation", () => {
+ it("should reject an oversized linear_create_issue title instead of truncating it", async () => {
+ const { validateItem } = await import("./safe_output_type_validator.cjs");
+
+ const oversizedTitle = "x".repeat(129);
+ const result = validateItem({ type: "linear_create_issue", title: oversizedTitle, body: "A sufficiently detailed body." }, "linear_create_issue", 1);
+
+ expect(result.isValid).toBe(false);
+ expect(result.error).toContain("exceeds maximum length");
+ });
+
+ it("should reject an oversized linear_create_issue body instead of truncating it", async () => {
+ const { validateItem } = await import("./safe_output_type_validator.cjs");
+
+ const oversizedBody = "x".repeat(65001);
+ const result = validateItem({ type: "linear_create_issue", title: "Valid title", body: oversizedBody }, "linear_create_issue", 1);
+
+ expect(result.isValid).toBe(false);
+ expect(result.error).toContain("exceeds maximum length");
+ });
+
+ it("should accept a linear_create_issue title/body within the configured limits", async () => {
+ const { validateItem } = await import("./safe_output_type_validator.cjs");
+
+ const result = validateItem({ type: "linear_create_issue", title: "Valid title", body: "A sufficiently detailed body." }, "linear_create_issue", 1);
+
+ expect(result.isValid).toBe(true);
+ });
+ });
+
describe("array validation", () => {
it("should validate array of strings", async () => {
const { validateItem } = await import("./safe_output_type_validator.cjs");
diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go
index 594037cd8c6..501f2f4990d 100644
--- a/pkg/workflow/compiler_safe_outputs_job.go
+++ b/pkg/workflow/compiler_safe_outputs_job.go
@@ -606,23 +606,26 @@ func (c *Compiler) buildSafeOutputsJobFromParts(
func (c *Compiler) buildPreambleTokenSteps(data *WorkflowData, outputs map[string]string) []string {
var preambleTokenSteps []string
if data.SafeOutputs.GitHubApp != nil {
- outputs["app_token_minting_failed"] = "${{ steps.safe-outputs-app-token.outcome == 'failure' }}"
appPermissions := computePermissionsForSafeOutputs(data.SafeOutputs, true)
if appPermissions != nil && len(appPermissions.permissions) == 0 {
- appPermissions = NewPermissionsFromMap(map[PermissionScope]PermissionLevel{
- PermissionMetadata: PermissionRead,
- })
- }
- var appTokenFallbackRepo string
- if hasWorkflowCallTrigger(data.On) {
- appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}"
+ // No enabled handler (e.g. a Linear-only configuration) consumes GitHub
+ // permissions from this global app, so skip minting an unrelated
+ // installation token purely because a top-level github-app was
+ // configured or auto-copied via applyTopLevelGitHubAppFallbacks.
+ safeOutputsPermissionsLog.Print("No GitHub-backed safe output handler enabled; skipping global GitHub App token minting")
+ } else {
+ outputs["app_token_minting_failed"] = "${{ steps.safe-outputs-app-token.outcome == 'failure' }}"
+ var appTokenFallbackRepo string
+ if hasWorkflowCallTrigger(data.On) {
+ appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}"
+ }
+ preambleTokenSteps = append(preambleTokenSteps, c.buildGitHubAppTokenMintStepForRepository(
+ data.SafeOutputs.GitHubApp,
+ appPermissions,
+ appTokenFallbackRepo,
+ inferSingleCheckoutRepositoryForGitHubAppOwner(data),
+ )...)
}
- preambleTokenSteps = append(preambleTokenSteps, c.buildGitHubAppTokenMintStepForRepository(
- data.SafeOutputs.GitHubApp,
- appPermissions,
- appTokenFallbackRepo,
- inferSingleCheckoutRepositoryForGitHubAppOwner(data),
- )...)
}
if headApp := getSafeOutputsHeadApp(data.SafeOutputs); headApp != nil {
headRepoSlug := getSafeOutputsHeadRepoSlug(data.SafeOutputs)
diff --git a/pkg/workflow/imports.go b/pkg/workflow/imports.go
index ec057c1e2d1..739bc07448b 100644
--- a/pkg/workflow/imports.go
+++ b/pkg/workflow/imports.go
@@ -298,6 +298,17 @@ func (c *Compiler) MergeSafeOutputs(topSafeOutputs *SafeOutputsConfig, importedS
}
}
+ // Recompute the implicit report-incomplete create-issue default now that imports are
+ // merged. The default is originally computed from only the main workflow's own
+ // safe-outputs block, before Linear (or other) handlers supplied by an import are
+ // visible. Without recomputing here, a main workflow that declares only global fields
+ // (e.g. linear-token) could inherit an implicit `create-issue: true` default meant for
+ // a GitHub-only configuration, even though the merged result is Linear-only.
+ if result.ReportIncomplete != nil && result.ReportIncomplete.Implicit {
+ recomputed := defaultReportIncompleteCreateIssue(result)
+ result.ReportIncomplete.CreateIssue = &recomputed
+ }
+
// Apply protected-files exclude lists accumulated from type-conflicting imports.
// These are merged as a set so that importing a base workflow can add to exclusions
// without completely replacing the main workflow's handler configuration.
@@ -445,6 +456,9 @@ func mergeSafeOutputConfig(result *SafeOutputsConfig, config map[string]any, c *
if result.GitHubToken == "" && importedConfig.GitHubToken != "" {
result.GitHubToken = importedConfig.GitHubToken
}
+ if result.LinearToken == "" && importedConfig.LinearToken != "" {
+ result.LinearToken = importedConfig.LinearToken
+ }
if result.GitHubApp == nil && importedConfig.GitHubApp != nil {
result.GitHubApp = importedConfig.GitHubApp
}
diff --git a/pkg/workflow/linear_safe_outputs_test.go b/pkg/workflow/linear_safe_outputs_test.go
index d0b99f6aec3..8eba3972e1a 100644
--- a/pkg/workflow/linear_safe_outputs_test.go
+++ b/pkg/workflow/linear_safe_outputs_test.go
@@ -129,3 +129,46 @@ func TestLinearTokenOnlyAddedToTrustedProcessingStep(t *testing.T) {
assert.Contains(t, processStep, "env:\n GH_AW_LINEAR_TOKEN:")
assert.NotContains(t, rendered[:strings.Index(rendered, "- name: Process Safe Outputs")], "GH_AW_LINEAR_TOKEN")
}
+
+// TestLinearOnlyWorkflowSkipsGlobalGitHubAppTokenMinting ensures that a Linear-only
+// safe-outputs configuration does not mint an unrelated GitHub App installation token,
+// even when a top-level (or auto-copied) SafeOutputs.GitHubApp is present. Minting a
+// GitHub App token here would violate the credential-separation guarantee between
+// Linear and GitHub App credentials.
+func TestLinearOnlyWorkflowSkipsGlobalGitHubAppTokenMinting(t *testing.T) {
+ data := &WorkflowData{SafeOutputs: &SafeOutputsConfig{
+ LinearToken: "${{ secrets.LINEAR_API_KEY }}",
+ LinearCreateIssue: &LinearCreateIssueConfig{TeamID: "9cfb482a-81e3-4154-b5b9-2c805e70a02d"},
+ GitHubApp: &GitHubAppConfig{
+ AppID: "${{ vars.APP_ID }}",
+ PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}",
+ },
+ }}
+ compiler := NewCompiler()
+ outputs := map[string]string{}
+ steps := compiler.buildPreambleTokenSteps(data, outputs)
+
+ rendered := strings.Join(steps, "")
+ assert.NotContains(t, rendered, "safe-outputs-app-token")
+ assert.NotContains(t, outputs, "app_token_minting_failed")
+}
+
+// TestGitHubHandlerWithGlobalAppStillMintsToken is the control case for
+// TestLinearOnlyWorkflowSkipsGlobalGitHubAppTokenMinting: when a GitHub-backed handler is
+// enabled alongside the global GitHub App, the app-token minting step must still be built.
+func TestGitHubHandlerWithGlobalAppStillMintsToken(t *testing.T) {
+ data := &WorkflowData{SafeOutputs: &SafeOutputsConfig{
+ AddComments: &AddCommentsConfig{},
+ GitHubApp: &GitHubAppConfig{
+ AppID: "${{ vars.APP_ID }}",
+ PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}",
+ },
+ }}
+ compiler := NewCompiler()
+ outputs := map[string]string{}
+ steps := compiler.buildPreambleTokenSteps(data, outputs)
+
+ rendered := strings.Join(steps, "")
+ assert.Contains(t, rendered, "safe-outputs-app-token")
+ assert.Contains(t, outputs, "app_token_minting_failed")
+}
diff --git a/pkg/workflow/missing_issue_reporting.go b/pkg/workflow/missing_issue_reporting.go
index ccb74934e61..0a49ef5f103 100644
--- a/pkg/workflow/missing_issue_reporting.go
+++ b/pkg/workflow/missing_issue_reporting.go
@@ -19,6 +19,7 @@ type IssueReportingConfig struct {
ReportAsFailure *string `yaml:"report-as-failure,omitempty"` // Whether to surface these signals as agent failures (default: true). Set to false to revert to old behavior. Supports literal bool or GitHub Actions expression.
TitlePrefix string `yaml:"title-prefix,omitempty"` // Prefix for issue titles
Labels []string `yaml:"labels,omitempty"` // Labels to add to created issues
+ Implicit bool `yaml:"-"` // True when this config was auto-defaulted rather than authored by the user; recomputed after import merges (e.g. for report-incomplete's create-issue default)
}
// Type aliases so existing code (compiler_types.go, tests, etc.) continues to compile unchanged.
diff --git a/pkg/workflow/safe_outputs_config_extraction.go b/pkg/workflow/safe_outputs_config_extraction.go
index 9b586764c75..14b5c29d3e4 100644
--- a/pkg/workflow/safe_outputs_config_extraction.go
+++ b/pkg/workflow/safe_outputs_config_extraction.go
@@ -394,6 +394,7 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut
CreateIssue: &trueVal,
TitlePrefix: "",
Labels: nil,
+ Implicit: true,
}
}
}
diff --git a/pkg/workflow/safe_outputs_import_test.go b/pkg/workflow/safe_outputs_import_test.go
index b2c699cd57a..5f3cfa06e8e 100644
--- a/pkg/workflow/safe_outputs_import_test.go
+++ b/pkg/workflow/safe_outputs_import_test.go
@@ -2431,3 +2431,130 @@ Run a task.
assert.Contains(t, needs, "imported_job", "safe_outputs needs should include deduped dependency from import")
assert.Contains(t, needs, "shared_job", "safe_outputs needs should include dependency from import")
}
+
+// TestSafeOutputsImportLinearTokenFromOnlyImport ensures that a Linear handler and its
+// linear-token supplied purely by an imported shared workflow retain the credential after
+// merge, mirroring the equivalent GitHubToken merge behavior.
+func TestSafeOutputsImportLinearTokenFromOnlyImport(t *testing.T) {
+ compiler := NewCompiler(WithVersion("1.0.0"))
+
+ tmpDir := t.TempDir()
+ workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
+ err := os.MkdirAll(workflowsDir, 0755)
+ require.NoError(t, err, "Failed to create workflows directory")
+
+ sharedWorkflow := `---
+safe-outputs:
+ linear-token: "${{ secrets.IMPORT_LINEAR_TOKEN }}"
+ linear-add-comment:
+ target: "ENG-123"
+---
+
+# Shared Linear Configuration
+`
+
+ sharedFile := filepath.Join(workflowsDir, "shared-linear.md")
+ err = os.WriteFile(sharedFile, []byte(sharedWorkflow), 0644)
+ require.NoError(t, err, "Failed to write shared file")
+
+ mainWorkflow := `---
+on: issues
+permissions:
+ contents: read
+imports:
+ - ./shared-linear.md
+---
+
+# Main Workflow
+
+This workflow uses only the imported Linear safe-outputs configuration.
+`
+
+ mainFile := filepath.Join(workflowsDir, "main.md")
+ err = os.WriteFile(mainFile, []byte(mainWorkflow), 0644)
+ require.NoError(t, err, "Failed to write main file")
+
+ oldDir, err := os.Getwd()
+ require.NoError(t, err, "Failed to get current directory")
+ err = os.Chdir(workflowsDir)
+ require.NoError(t, err, "Failed to change directory")
+ defer func() { _ = os.Chdir(oldDir) }()
+
+ workflowData, err := compiler.ParseWorkflowFile("main.md")
+ require.NoError(t, err, "Failed to parse workflow")
+ require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil")
+
+ require.NotNil(t, workflowData.SafeOutputs.LinearAddComment, "LinearAddComment should be imported")
+ assert.Equal(t, "ENG-123", workflowData.SafeOutputs.LinearAddComment.Target)
+ assert.Equal(t, "${{ secrets.IMPORT_LINEAR_TOKEN }}", workflowData.SafeOutputs.LinearToken, "LinearToken should be merged from the import")
+
+ // The Linear-only configuration must not implicitly enable GitHub issue creation
+ // for incomplete-run reporting.
+ require.NotNil(t, workflowData.SafeOutputs.ReportIncomplete)
+ require.NotNil(t, workflowData.SafeOutputs.ReportIncomplete.CreateIssue)
+ assert.Equal(t, "false", *workflowData.SafeOutputs.ReportIncomplete.CreateIssue, "ReportIncomplete.CreateIssue should default to false for a Linear-only merged configuration")
+}
+
+// TestSafeOutputsImportLinearOnlyDoesNotEnableGitHubIssueReporting is a regression test for
+// the implicit report-incomplete default being computed before imported Linear handlers are
+// merged in. Previously, a main workflow declaring only meta safe-outputs fields (like
+// linear-token) would get an implicit "create-issue: true" default before the import's Linear
+// handler was merged in, and that implicit true value would then be preserved by the merge
+// because the import did not explicitly set report-incomplete.
+func TestSafeOutputsImportLinearOnlyDoesNotEnableGitHubIssueReporting(t *testing.T) {
+ compiler := NewCompiler(WithVersion("1.0.0"))
+
+ tmpDir := t.TempDir()
+ workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
+ err := os.MkdirAll(workflowsDir, 0755)
+ require.NoError(t, err, "Failed to create workflows directory")
+
+ sharedWorkflow := `---
+safe-outputs:
+ linear-create-issue:
+ team-id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d"
+---
+
+# Shared Linear Configuration
+`
+
+ sharedFile := filepath.Join(workflowsDir, "shared-linear.md")
+ err = os.WriteFile(sharedFile, []byte(sharedWorkflow), 0644)
+ require.NoError(t, err, "Failed to write shared file")
+
+ // Main workflow declares a safe-outputs section (so the implicit default gets
+ // computed before merge) but only sets meta fields, not any GitHub-backed handler.
+ mainWorkflow := `---
+on: issues
+permissions:
+ contents: read
+imports:
+ - ./shared-linear.md
+safe-outputs:
+ linear-token: "${{ secrets.LINEAR_API_KEY }}"
+---
+
+# Main Workflow
+`
+
+ mainFile := filepath.Join(workflowsDir, "main.md")
+ err = os.WriteFile(mainFile, []byte(mainWorkflow), 0644)
+ require.NoError(t, err, "Failed to write main file")
+
+ oldDir, err := os.Getwd()
+ require.NoError(t, err, "Failed to get current directory")
+ err = os.Chdir(workflowsDir)
+ require.NoError(t, err, "Failed to change directory")
+ defer func() { _ = os.Chdir(oldDir) }()
+
+ workflowData, err := compiler.ParseWorkflowFile("main.md")
+ require.NoError(t, err, "Failed to parse workflow")
+ require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil")
+
+ require.NotNil(t, workflowData.SafeOutputs.LinearCreateIssue, "LinearCreateIssue should be imported")
+ require.Nil(t, workflowData.SafeOutputs.CreateIssues, "CreateIssues (GitHub) must not be implicitly enabled")
+ require.NotNil(t, workflowData.SafeOutputs.ReportIncomplete)
+ require.NotNil(t, workflowData.SafeOutputs.ReportIncomplete.CreateIssue)
+ assert.Equal(t, "false", *workflowData.SafeOutputs.ReportIncomplete.CreateIssue, "ReportIncomplete.CreateIssue should be recomputed to false after merging the imported Linear-only handler")
+ assert.Empty(t, computePermissionsForSafeOutputs(workflowData.SafeOutputs, false).permissions, "Linear-only merged configuration should not request GitHub write permissions")
+}
diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go
index 70128d9a276..da2fe5b5e9b 100644
--- a/pkg/workflow/safe_outputs_validation_config.go
+++ b/pkg/workflow/safe_outputs_validation_config.go
@@ -32,6 +32,11 @@ type FieldValidation struct {
Pattern string `json:"pattern,omitempty"`
PatternError string `json:"patternError,omitempty"`
TemporaryID bool `json:"temporaryId,omitempty"`
+ // RejectIfOversized rejects the field outright when the raw value exceeds MaxLength,
+ // instead of silently truncating it via sanitization. Used for external-system fields
+ // (e.g. Linear) where truncation could turn an oversized/placeholder value into a
+ // deceptively short but "valid" operation.
+ RejectIfOversized bool `json:"rejectIfOversized,omitempty"`
// StripOnError marks optional enrichment fields (e.g. confidence, rationale) that should be
// silently dropped when they fail validation instead of rejecting the entire item.
// Serialised as "x-strip-on-error" to follow the x- extension convention used in JSON Schema.
@@ -63,21 +68,21 @@ var ValidationConfig = map[string]TypeValidationConfig{
"linear_create_issue": {
DefaultMax: 1,
Fields: map[string]FieldValidation{
- "title": {Required: true, Type: "string", Sanitize: true, MaxLength: 128},
- "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength, MinLength: MinIssueBodyLength},
+ "title": {Required: true, Type: "string", Sanitize: true, MaxLength: 128, RejectIfOversized: true},
+ "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength, MinLength: MinIssueBodyLength, RejectIfOversized: true},
},
},
"linear_add_comment": {
DefaultMax: 1,
Fields: map[string]FieldValidation{
- "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength},
+ "body": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength, RejectIfOversized: true},
},
},
"linear_update_issue": {
DefaultMax: 1,
Fields: map[string]FieldValidation{
- "title": {Type: "string", Sanitize: true, MaxLength: 128},
- "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength},
+ "title": {Type: "string", Sanitize: true, MaxLength: 128, RejectIfOversized: true},
+ "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength, RejectIfOversized: true},
},
},
"create_issue": {