Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/aw/safe-outputs-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ description: Safe-output reference for workflow dispatch, code scanning, checks,
update-discussion:
title: true # Optional: enable title updates
body: true # Optional: enable body updates
body-file: true # Optional: opt in to body_file + body_sha256 under RUNNER_TEMP/gh-aw-safe/
labels: true # Optional: enable label updates
allowed-labels: [status, type] # Optional: restrict to specific labels
max: 1 # Optional: max updates (default: 1)
target: "*" # Optional: "triggering" (default), "*", or number
target-repo: "owner/repo" # Optional: cross-repository
```

When `body-file: true` is enabled, the agent may send `body_file` plus `body_sha256` instead of inline `body`. The file must stay under `$RUNNER_TEMP/gh-aw-safe/`, must be UTF-8 text, cannot be a symlink, is read exactly once at execution time, and must match the supplied SHA-256 digest. Inline `body` and `body_file` are mutually exclusive.

- `update-release:` - Update GitHub release descriptions

```yaml
Expand Down
3 changes: 3 additions & 0 deletions .github/aw/safe-outputs-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ description: Safe-output reference for update, label, milestone, project, releas
target: "*" # Optional: target for updates (default: "triggering")
title: true # Optional: allow updating issue title
body: true # Optional: allow updating issue body
body-file: true # Optional: opt in to body_file + body_sha256 under RUNNER_TEMP/gh-aw-safe/
max: 3 # Optional: maximum number of issues to update (default: 1)
target-repo: "owner/repo" # Optional: cross-repository
```
Expand All @@ -25,6 +26,7 @@ description: Safe-output reference for update, label, milestone, project, releas
update-pull-request:
title: true # Optional: enable title updates (default: true)
body: true # Optional: enable body updates (default: true)
body-file: true # Optional: opt in to body_file + body_sha256 under RUNNER_TEMP/gh-aw-safe/
operation: "replace" # Optional: "replace" (default), "append", "prepend"
update-branch: false # Optional: update PR branch with latest base before updates (default: false)
max: 1 # Optional: max updates (default: 1)
Expand All @@ -33,6 +35,7 @@ description: Safe-output reference for update, label, milestone, project, releas
```

Operation types: `replace` (default), `append`, `prepend`.
When `body-file: true` is enabled, the agent may send `body_file` plus `body_sha256` instead of inline `body`. The file must stay under `$RUNNER_TEMP/gh-aw-safe/`, must be UTF-8 text, cannot be a symlink, is read exactly once at execution time, and must match the supplied SHA-256 digest. Inline `body` and `body_file` are mutually exclusive.
- `merge-pull-request:` - Merge pull requests under configured policy gates (experimental)

```yaml
Expand Down
132 changes: 132 additions & 0 deletions actions/setup/js/body_file_helpers.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// @ts-check

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const { getErrorMessage } = require("./error_helpers.cjs");
const { lstatGuard } = require("./symlink_guard.cjs");

const SAFE_BODY_FILE_DIRNAME = "gh-aw-safe";
const MAX_BODY_FILE_BYTES = 65536;

function getSafeBodyFileRoot() {
const runnerTemp = process.env.RUNNER_TEMP || "/tmp";
return path.resolve(runnerTemp, SAFE_BODY_FILE_DIRNAME);
}

function normalizeAuditPath(filePath) {
const allowlistedRoot = getSafeBodyFileRoot();
const relative = path.relative(allowlistedRoot, filePath);
return relative && relative !== "." ? `${SAFE_BODY_FILE_DIRNAME}/${relative.split(path.sep).join("/")}` : SAFE_BODY_FILE_DIRNAME;
}

function ensurePathHasNoSymlinks(candidatePath, allowlistedRoot) {
const relative = path.relative(allowlistedRoot, candidatePath);
const segments = relative.split(path.sep).filter(Boolean);
let currentPath = allowlistedRoot;

if (lstatGuard(currentPath) === null) {
throw new Error(`Rejected body_file '${normalizeAuditPath(candidatePath)}': allowlisted root is a symbolic link`);
}

for (const segment of segments) {
currentPath = path.join(currentPath, segment);
if (lstatGuard(currentPath) === null) {
throw new Error(`Rejected body_file '${normalizeAuditPath(candidatePath)}': symbolic links are not allowed`);
}
}
}

function resolveBodyFilePath(bodyFile) {
const rawValue = typeof bodyFile === "string" ? bodyFile.trim() : "";
if (!rawValue) {
throw new Error("body_file must be a non-empty string");
}

const allowlistedRoot = getSafeBodyFileRoot();
const candidatePath = path.isAbsolute(rawValue) ? path.resolve(rawValue) : path.resolve(process.env.RUNNER_TEMP || "/tmp", rawValue);
const relative = path.relative(allowlistedRoot, candidatePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Rejected body_file '${rawValue}': path must stay under ${SAFE_BODY_FILE_DIRNAME}/`);
}
if (!fs.existsSync(candidatePath)) {
throw new Error(`Rejected body_file '${rawValue}': file does not exist`);
}

ensurePathHasNoSymlinks(candidatePath, allowlistedRoot);

let stat;
try {
stat = fs.statSync(candidatePath);
} catch (error) {
throw new Error(`Rejected body_file '${rawValue}': ${getErrorMessage(error)}`);
}
if (!stat.isFile()) {
throw new Error(`Rejected body_file '${rawValue}': path is not a regular file`);
}
if (stat.size > MAX_BODY_FILE_BYTES) {
throw new Error(`Rejected body_file '${rawValue}': file is too large (${stat.size} bytes > ${MAX_BODY_FILE_BYTES} bytes)`);
}

return candidatePath;
}

function readBodyFileSnapshot(bodyFile, expectedSha256) {
const resolvedPath = resolveBodyFilePath(bodyFile);
const openFlags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
let fd;
try {
fd = fs.openSync(resolvedPath, openFlags);
} catch (error) {
throw new Error(`Rejected body_file '${bodyFile}': ${getErrorMessage(error)}`);
}
try {
const stat = fs.fstatSync(fd);
if (!stat.isFile()) {
throw new Error(`Rejected body_file '${bodyFile}': path is not a regular file`);
}
if (stat.size > MAX_BODY_FILE_BYTES) {
throw new Error(`Rejected body_file '${bodyFile}': file is too large (${stat.size} bytes > ${MAX_BODY_FILE_BYTES} bytes)`);
}
let fileBytes;
try {
fileBytes = fs.readFileSync(fd);
} catch (error) {
throw new Error(`Rejected body_file '${bodyFile}': ${getErrorMessage(error)}`);
}
const digest = crypto.createHash("sha256").update(fileBytes).digest("hex");
if (digest !== expectedSha256) {
throw new Error(`Rejected body_file '${bodyFile}': body_sha256 mismatch`);
}
if (fileBytes.includes(0)) {
throw new Error(`Rejected body_file '${bodyFile}': file must be UTF-8 text`);
}

let content;
try {
content = new TextDecoder("utf-8", { fatal: true }).decode(fileBytes);
} catch {
throw new Error(`Rejected body_file '${bodyFile}': file must be UTF-8 text`);
}

return {
content,
metadata: {
path: normalizeAuditPath(resolvedPath),
sha256: digest,
bytes: fileBytes.length,
},
};
} finally {
fs.closeSync(fd);
}
}

module.exports = {
SAFE_BODY_FILE_DIRNAME,
MAX_BODY_FILE_BYTES,
getSafeBodyFileRoot,
readBodyFileSnapshot,
resolveBodyFilePath,
};
79 changes: 79 additions & 0 deletions actions/setup/js/body_file_helpers.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

let helpers;
let runnerTemp;

describe("body_file_helpers", () => {
beforeEach(async () => {
runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-body-file-"));
process.env.RUNNER_TEMP = runnerTemp;
helpers = await import("./body_file_helpers.cjs");
fs.mkdirSync(helpers.getSafeBodyFileRoot(), { recursive: true });
});

afterEach(() => {
fs.rmSync(runnerTemp, { recursive: true, force: true });
delete process.env.RUNNER_TEMP;
});

it("reads an allowlisted UTF-8 file once and returns audit metadata", () => {
const filePath = path.join(helpers.getSafeBodyFileRoot(), "body.md");
fs.writeFileSync(filePath, "hello body\n");
const digest = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");

const result = helpers.readBodyFileSnapshot("gh-aw-safe/body.md", digest);

expect(result).toEqual({
content: "hello body\n",
metadata: {
path: "gh-aw-safe/body.md",
sha256: digest,
bytes: 11,
},
});
});

it("rejects paths outside the allowlisted directory", () => {
const outsidePath = path.join(runnerTemp, "outside.md");
fs.writeFileSync(outsidePath, "nope");
const digest = crypto.createHash("sha256").update(fs.readFileSync(outsidePath)).digest("hex");

expect(() => helpers.readBodyFileSnapshot(outsidePath, digest)).toThrow(/path must stay under gh-aw-safe\//i);
});

it("rejects SHA-256 mismatches", () => {
const filePath = path.join(helpers.getSafeBodyFileRoot(), "body.md");
fs.writeFileSync(filePath, "hello");

expect(() => helpers.readBodyFileSnapshot("gh-aw-safe/body.md", "a".repeat(64))).toThrow(/body_sha256 mismatch/i);
});

it("rejects binary files", () => {
const filePath = path.join(helpers.getSafeBodyFileRoot(), "body.bin");
fs.writeFileSync(filePath, Buffer.from([0x68, 0x69, 0x00, 0xff]));
const digest = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");

expect(() => helpers.readBodyFileSnapshot("gh-aw-safe/body.bin", digest)).toThrow(/UTF-8 text/i);
});

it("rejects oversized files", () => {
const filePath = path.join(helpers.getSafeBodyFileRoot(), "body.md");
fs.writeFileSync(filePath, "a".repeat(helpers.MAX_BODY_FILE_BYTES + 1));
const digest = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");

expect(() => helpers.readBodyFileSnapshot("gh-aw-safe/body.md", digest)).toThrow(/file is too large/i);
});

it("rejects symlink escapes", () => {
const targetPath = path.join(runnerTemp, "outside.md");
fs.writeFileSync(targetPath, "secret");
fs.symlinkSync(targetPath, path.join(helpers.getSafeBodyFileRoot(), "body.md"));
const digest = crypto.createHash("sha256").update(fs.readFileSync(targetPath)).digest("hex");

expect(() => helpers.readBodyFileSnapshot("gh-aw-safe/body.md", digest)).toThrow(/symbolic links are not allowed/i);
});
});
77 changes: 52 additions & 25 deletions actions/setup/js/safe_output_type_validator.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -632,39 +632,66 @@ function executeCustomValidation(item, customValidation, lineNum, itemType) {
return null;
}

// Parse custom validation rule
if (customValidation.startsWith("requiresOneOf:")) {
const fields = customValidation.slice("requiresOneOf:".length).split(",");
const hasValidField = fields.some(field => item[field] !== undefined && item[field] !== false);
if (!hasValidField) {
return {
isValid: false,
error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`,
};
const rules = customValidation
.split(";")
.map(rule => rule.trim())
.filter(Boolean);
for (const rule of rules) {
if (rule.startsWith("requiresOneOf:")) {
const fields = rule.slice("requiresOneOf:".length).split(",");
const hasValidField = fields.some(field => item[field] !== undefined && item[field] !== false);
if (!hasValidField) {
return {
isValid: false,
error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`,
};
}
}
}

if (customValidation === "startLineLessOrEqualLine") {
if (item.start_line !== undefined && item.line !== undefined) {
const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line;
const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line;
if (startLine > endLine) {
if (rule.startsWith("pairedFields:")) {
const [leftField, rightField] = rule.slice("pairedFields:".length).split(",");
const leftPresent = item[leftField] !== undefined;
const rightPresent = item[rightField] !== undefined;
if (leftPresent !== rightPresent) {
return {
isValid: false,
error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`,
error: `Line ${lineNum}: ${itemType} requires '${leftField}' and '${rightField}' to be provided together`,
};
}
}
}

if (customValidation === "parentAndSubDifferent") {
// Normalize values for comparison
const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v);
if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) {
return {
isValid: false,
error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`,
};
if (rule.startsWith("mutuallyExclusive:")) {
const [leftField, rightField] = rule.slice("mutuallyExclusive:".length).split(",");
if (item[leftField] !== undefined && item[rightField] !== undefined) {
return {
isValid: false,
error: `Line ${lineNum}: ${itemType} '${leftField}' and '${rightField}' cannot be used together`,
};
}
}

if (rule === "startLineLessOrEqualLine") {
if (item.start_line !== undefined && item.line !== undefined) {
const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line;
const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line;
if (startLine > endLine) {
return {
isValid: false,
error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`,
};
}
}
}

if (rule === "parentAndSubDifferent") {
// Normalize values for comparison
const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v);
if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) {
return {
isValid: false,
error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`,
};
}
}
}

Expand Down
Loading