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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion setup/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const inputParentSpanId = getActionInput("PARENT_SPAN_ID");
const inputJobName = getActionInput("JOB_NAME");
const inputOTLPOIDCToken = getActionInput("OTLP_OIDC_TOKEN");

const result = spawnSync(path.join(__dirname, "setup.sh"), [], {
const result = spawnSync("bash", [path.join(__dirname, "setup.sh")], {
stdio: "inherit",
env: Object.assign({}, process.env, {
INPUT_SAFE_OUTPUT_CUSTOM_TOKENS: safeOutputCustomTokens,
Expand Down
7 changes: 7 additions & 0 deletions setup/js/convert_gateway_config_copilot.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ function resolveCopilotConfigOutputPath() {
*/
function transformCopilotEntry(entry, urlPrefix) {
return normalizeGatewayEntry(entry, urlPrefix, transformed => {
// The gateway expresses backend tool timeouts in seconds, while Copilot
// expects its client-side per-server timeout in milliseconds.
if (transformed.timeout === undefined && typeof transformed.toolTimeout === "number" && Number.isFinite(transformed.toolTimeout) && transformed.toolTimeout > 0) {
transformed.timeout = transformed.toolTimeout * 1000;
}
delete transformed.toolTimeout;

// Add tools field if not present
if (!transformed.tools) {
transformed.tools = ["*"];
Expand Down
212 changes: 211 additions & 1 deletion setup/js/frontmatter_hash_pure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ async function computeFrontmatterHash(workflowPath, options = {}) {
log(`canonical.body-text (inlined-imports): ${canonical["body-text"].substring(0, 200)}...`);
} else {
// Extract template expressions with env. or vars.
const expressions = extractRelevantTemplateExpressions(markdown);
const expressions = mergeSortedUniqueStrings(extractRelevantTemplateExpressions(markdown), await collectRuntimeImportTemplateExpressions(frontmatterText, markdown, baseDir, fileReader));
if (expressions.length > 0) {
canonical["template-expressions"] = expressions;
log(`canonical.template-expressions: ${JSON.stringify(expressions)}`);
Expand Down Expand Up @@ -349,6 +349,213 @@ function extractRelevantTemplateExpressions(markdown) {
return [...new Set(expressions)].sort();
}

/**
* Extract all GitHub Actions template expressions.
* @param {string} markdown
* @returns {string[]}
*/
function extractAllTemplateExpressions(markdown) {
const expressions = [];
const regex = /\$\{\{([^}]+)\}\}/g;
let match;

while ((match = regex.exec(markdown)) !== null) {
if (!match[1].trim()) continue;
expressions.push(match[0]);
}

return [...new Set(expressions)].sort();
}

/**
* @typedef {Object} RuntimeImportReference
* @property {string} path
* @property {number|null} startLine
* @property {number|null} endLine
*/

/**
* Collect template expressions from content that may be added to the prompt by
* runtime imports.
* @param {string} frontmatterText
* @param {string} markdown
* @param {string} baseDir
* @param {Function} fileReader
* @returns {Promise<string[]>}
*/
async function collectRuntimeImportTemplateExpressions(frontmatterText, markdown, baseDir, fileReader) {
const seen = new Set();
let expressions = await extractRuntimeImportTemplateExpressionsFromMarkdown(markdown, baseDir, seen, fileReader);

const importedBodies = await collectImportedBodies(frontmatterText, baseDir, new Set(), fileReader);
for (const body of importedBodies) {
expressions = mergeSortedUniqueStrings(expressions, extractAllTemplateExpressions(body));
expressions = mergeSortedUniqueStrings(expressions, await extractRuntimeImportTemplateExpressionsFromMarkdown(body, baseDir, seen, fileReader));
}

return expressions;
}

/**
* @param {string} markdown
* @param {string} baseDir
* @param {Set<string>} seen
* @param {Function} fileReader
* @returns {Promise<string[]>}
*/
async function extractRuntimeImportTemplateExpressionsFromMarkdown(markdown, baseDir, seen, fileReader) {
const refs = extractRuntimeImportReferences(markdown);
let expressions = [];

for (const ref of refs) {
const body = await readRuntimeImportBodyForHash(ref, baseDir, seen, fileReader);
if (body === null) continue;
expressions = mergeSortedUniqueStrings(expressions, extractAllTemplateExpressions(body));
expressions = mergeSortedUniqueStrings(expressions, await extractRuntimeImportTemplateExpressionsFromMarkdown(body, baseDir, seen, fileReader));
}

return expressions;
}

/**
* @param {string} markdown
* @returns {RuntimeImportReference[]}
*/
function extractRuntimeImportReferences(markdown) {
if (!markdown) return [];

const refs = [];
const seen = new Set();
const regex = /\{\{#(?:runtime-import|import)\??(?:[ \t]+|[ \t]*:[ \t]*)([^{}]+?)\}\}/g;
let match;
while ((match = regex.exec(markdown)) !== null) {
const target = match[1].trim();
if (!target) continue;
const rangeMatch = target.match(/^(.+?):(\d+)-(\d+)$/);
const ref = rangeMatch ? { path: rangeMatch[1].trim(), startLine: Number.parseInt(rangeMatch[2], 10), endLine: Number.parseInt(rangeMatch[3], 10) } : { path: target, startLine: null, endLine: null };
if (/^https?:\/\//i.test(ref.path)) continue;
const key = `${ref.path.replace(/\\/g, "/")}:${ref.startLine ?? 0}-${ref.endLine ?? 0}`;
if (seen.has(key)) continue;
refs.push(ref);
seen.add(key);
}
return refs;
}

/**
* @param {RuntimeImportReference} ref
* @param {string} baseDir
* @param {Set<string>} seen
* @param {Function} fileReader
* @returns {Promise<string|null>}
*/
async function readRuntimeImportBodyForHash(ref, baseDir, seen, fileReader) {
for (const candidate of runtimeImportHashCandidatePaths(ref.path, baseDir)) {
const key = `${candidate}:${ref.startLine ?? 0}-${ref.endLine ?? 0}`;
if (seen.has(key)) return null;
if (fs.existsSync(candidate) && !runtimeImportHashRealPathAllowed(candidate, baseDir)) continue;
try {
let content = await fileReader(candidate);
seen.add(key);
if (ref.startLine !== null || ref.endLine !== null) {
content = applyRuntimeImportLineRangeForHash(content, ref.startLine, ref.endLine);
}
const { markdown } = extractFrontmatterAndBody(content);
return markdown;
} catch (err) {
continue;
}
}
return null;
}

function runtimeImportHashRealPathAllowed(candidate, baseDir) {
const workspaceRoot = runtimeImportHashWorkspaceRoot(baseDir);
for (const base of [path.join(workspaceRoot, ".github"), path.join(workspaceRoot, ".agents")]) {
if (realPathWithinBaseForHash(candidate, base)) return true;
}
return false;
}

function realPathWithinBaseForHash(pathToCheck, baseDir) {
try {
const realBase = fs.realpathSync(baseDir);
const realPath = fs.realpathSync(pathToCheck);
const relativePath = path.relative(realBase, realPath);
return relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath);
} catch {
return false;
}
}

/**
* @param {string} content
* @param {number|null} startLine
* @param {number|null} endLine
* @returns {string}
*/
function applyRuntimeImportLineRangeForHash(content, startLine, endLine) {
const lines = content.split("\n");
const start = startLine && startLine > 0 ? startLine : 1;
const end = endLine && endLine > 0 && endLine <= lines.length ? endLine : lines.length;
if (start > end || start > lines.length) return "";
return lines.slice(start - 1, end).join("\n");
}

/**
* @param {string} importPath
* @param {string} baseDir
* @returns {string[]}
*/
function runtimeImportHashCandidatePaths(importPath, baseDir) {
let normalized = String(importPath || "")
.trim()
.replace(/\\/g, "/");
if (normalized.startsWith("/")) {
normalized = normalized.replace(/^\/+/, "");
if (!normalized.startsWith(".github/") && !normalized.startsWith(".agents/")) {
return [];
}
}
if (normalized.startsWith("./")) {
normalized = normalized.substring(2);
}
if (!normalized || normalized.startsWith("../") || normalized.includes("/../")) {
return [];
}

const workspaceRoot = runtimeImportHashWorkspaceRoot(baseDir);
if (normalized.startsWith(".agents/") || normalized.startsWith(".github/")) {
return [path.join(workspaceRoot, normalized)];
}

return [path.join(workspaceRoot, ".github", normalized), path.join(workspaceRoot, ".github", "workflows", normalized), path.join(baseDir, normalized)].map(p => path.normalize(p)).filter((p, index, all) => all.indexOf(p) === index);
}

/**
* @param {string} baseDir
* @returns {string}
*/
function runtimeImportHashWorkspaceRoot(baseDir) {
const normalized = baseDir.replace(/\\/g, "/");
const markerIndex = normalized.indexOf("/.github/");
Comment on lines +540 to +541
if (markerIndex >= 0) {
return normalized.substring(0, markerIndex);
}
if (normalized.endsWith("/.github")) {
return path.dirname(baseDir);
}
return baseDir;
}

/**
* @param {...string[]} groups
* @returns {string[]}
*/
function mergeSortedUniqueStrings(...groups) {
return [...new Set(groups.flat().filter(Boolean))].sort();
}

/**
* Marshals data to canonical JSON with sorted keys
* @param {any} data - The data to marshal
Expand Down Expand Up @@ -743,6 +950,9 @@ module.exports = {
extractFrontmatterAndBody,
extractImportsFromText,
extractRelevantTemplateExpressions,
extractAllTemplateExpressions,
collectRuntimeImportTemplateExpressions,
extractRuntimeImportReferences,
marshalCanonicalJSON,
marshalSorted,
extractHashFromLockFile,
Expand Down
2 changes: 1 addition & 1 deletion setup/js/generate_git_bundle.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ async function generateGitBundle(branchName, baseBranch, options = {}) {
try {
execGitSync([...noHooksArgs, "worktree", "add", "--detach", tempWorktree, baseCommitSha], { cwd });
execGitSync([...noHooksArgs, "am", "--3way", patchResult.patchPath], { cwd: tempWorktree });
execGitSync(["bundle", "create", bundlePath, `${baseCommitSha}..HEAD`], { cwd: tempWorktree });
execGitSync([...noHooksArgs, "bundle", "create", bundlePath, `${baseCommitSha}..HEAD`], { cwd: tempWorktree });
} finally {
try {
execGitSync([...noHooksArgs, "worktree", "remove", "--force", tempWorktree], { cwd });
Expand Down
51 changes: 45 additions & 6 deletions setup/js/runtime_import.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { closeUnterminatedSubAgentMarkers } = require("./extract_inline_sub_agent

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

/**
* Makes any "## skill:"/"## agent:" block in a runtime-imported chunk of
Expand Down Expand Up @@ -400,6 +401,15 @@ function isSafeExpression(expr) {
return false;
}

/**
* Generates the compiler's hash-based environment variable name for a GitHub expression.
* @param {string} expr - The GitHub expression content without ${{ }}.
* @returns {string}
*/
function generateHashedExpressionEnvVarName(expr) {
return "GH_AW_EXPR_" + crypto.createHash("sha256").update(expr).digest("hex").slice(0, 8).toUpperCase();
}

/**
* Evaluates a safe GitHub Actions expression at runtime
* @param {string} expr - The expression to evaluate (without ${{ }})
Expand Down Expand Up @@ -455,6 +465,12 @@ function evaluateExpression(expr) {
if (envValue !== undefined && envValue !== null) {
return envValue;
}

const hashedEnvVarName = generateHashedExpressionEnvVarName(trimmed);
const hashedEnvValue = process.env[hashedEnvVarName];
if (hashedEnvValue !== undefined && hashedEnvValue !== null) {
return hashedEnvValue;
}
// If not found in environment, continue to try other evaluation methods below
}

Expand Down Expand Up @@ -972,7 +988,7 @@ function generatePlaceholderName(expr) {
* Resolves a runtime-import file path to its normalized absolute path.
* @param {string} filepathOrUrl - File path (not URL)
* @param {string} workspaceDir - The GITHUB_WORKSPACE directory path
* @returns {{filepath: string, normalizedPath: string}}
* @returns {{filepath: string, normalizedPath: string, normalizedBaseFolder: string}}
*/
function resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir) {
if (/^https?:\/\//i.test(filepathOrUrl)) {
Expand Down Expand Up @@ -1031,11 +1047,11 @@ function resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir) {
baseFolder = workspaceDir;
absolutePath = path.resolve(workspaceDir, filepath);
normalizedPath = path.normalize(absolutePath);
normalizedBaseFolder = path.normalize(baseFolder);
normalizedBaseFolder = path.normalize(path.join(workspaceDir, ".agents"));

// Security check: ensure the resolved path is within the workspace
const relativePath = path.relative(normalizedBaseFolder, normalizedPath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
const relativePath = path.relative(path.normalize(baseFolder), normalizedPath);
if (relativePath === ".." || relativePath.startsWith(".." + path.sep) || path.isAbsolute(relativePath)) {
throw new Error(`${ERR_CONFIG}: Security: Path ${filepathOrUrl} must be within workspace (resolves to: ${relativePath})`);
}
// Additional check: ensure path stays within .agents folder
Expand All @@ -1057,7 +1073,29 @@ function resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir) {
}
}

return { filepath, normalizedPath };
return { filepath, normalizedPath, normalizedBaseFolder };
}

/**
* Verifies the resolved runtime-import target remains inside the allowed base
* after following symlinks.
* @param {string} normalizedPath
* @param {string} normalizedBaseFolder
* @param {string} filepathOrUrl
*/
function assertRuntimeImportRealPathWithinBase(normalizedPath, normalizedBaseFolder, filepathOrUrl) {
let realPath;
let realBase;
try {
realPath = fs.realpathSync(normalizedPath);
realBase = fs.realpathSync(normalizedBaseFolder);
} catch (err) {
throw new Error(`${ERR_SYSTEM}: Failed to resolve runtime import path ${filepathOrUrl}: ${getErrorMessage(err)}`, { cause: err });
}
const relativePath = path.relative(realBase, realPath);
if (relativePath === ".." || relativePath.startsWith(".." + path.sep) || path.isAbsolute(relativePath)) {
throw new Error(`${ERR_VALIDATION}: Security: Path ${filepathOrUrl} must remain within its allowed folder after resolving symlinks`);
}
}

/**
Expand All @@ -1077,7 +1115,7 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start
}

// Otherwise, process as a file
const { filepath, normalizedPath } = resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir);
const { filepath, normalizedPath, normalizedBaseFolder } = resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir);

// Check if file exists
if (!fs.existsSync(normalizedPath)) {
Expand All @@ -1087,6 +1125,7 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start
}
throw new Error(`${ERR_SYSTEM}: Runtime import file not found: ${normalizedPath}`);
}
assertRuntimeImportRealPathWithinBase(normalizedPath, normalizedBaseFolder, filepathOrUrl);

// Read the file
let content;
Expand Down
Loading
Loading