Restrict auto-apply to trusted review comments so spoofed issue comments cannot write optimized SKILL.md content into pull request branches. Reject activation symlinks that escape the source root and add regression coverage for both security checks.
268 lines
7.3 KiB
JavaScript
268 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const REVIEW_HEADING = '## Tessl Skill Review';
|
|
const TRUSTED_AUTHOR_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
|
const DEFAULT_ALLOWED_REVIEW_BOT_LOGINS = ['github-actions[bot]'];
|
|
|
|
function getRequiredEnv(name) {
|
|
const value = process.env[name];
|
|
if (!value) {
|
|
throw new Error(`${name} is required`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function githubRequest(pathname, options = {}) {
|
|
const token = getRequiredEnv('GITHUB_TOKEN');
|
|
const response = await fetch(`https://api.github.com${pathname}`, {
|
|
...options,
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
Authorization: `Bearer ${token}`,
|
|
'User-Agent': 'antigravity-awesome-skills/apply-skill-optimization',
|
|
...(options.headers || {}),
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`GitHub API ${pathname} failed (${response.status}): ${text}`);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
function runGit(args, options = {}) {
|
|
return execFileSync('git', args, {
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function extractKeyImprovements(body) {
|
|
const section = body.match(/\*\*Key improvements:\*\*\n((?:- .+\n?)+)/);
|
|
if (!section) {
|
|
return [];
|
|
}
|
|
|
|
return [...section[1].matchAll(/^- (.+)$/gm)].map((match) => match[1]);
|
|
}
|
|
|
|
function extractOptimizedContent(body) {
|
|
const sections = new Map();
|
|
const normalized = body.replace(/\r\n/g, '\n');
|
|
const regex =
|
|
/### `([^`]+)`[\s\S]*?View full optimized SKILL\.md[\s\S]*?```markdown\n([\s\S]*?)\n```/g;
|
|
|
|
let match;
|
|
while ((match = regex.exec(normalized)) !== null) {
|
|
const skillPath = match[1];
|
|
const content = match[2].replace(/` ` `/g, '```');
|
|
sections.set(skillPath, content);
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
function ensureRepoRelative(filePath) {
|
|
const cwd = process.cwd();
|
|
const resolved = path.resolve(cwd, filePath);
|
|
const relative = path.relative(cwd, resolved);
|
|
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
throw new Error(`Path traversal detected: ${filePath}`);
|
|
}
|
|
|
|
if (!filePath.endsWith('SKILL.md')) {
|
|
throw new Error(`Unexpected file path (expected SKILL.md): ${filePath}`);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
function parseAllowedReviewBotLogins(envValue = process.env.APPLY_OPTIMIZE_ALLOWED_REVIEW_BOT_LOGINS) {
|
|
if (!envValue) {
|
|
return DEFAULT_ALLOWED_REVIEW_BOT_LOGINS;
|
|
}
|
|
|
|
const logins = envValue
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
|
|
return logins.length > 0 ? logins : DEFAULT_ALLOWED_REVIEW_BOT_LOGINS;
|
|
}
|
|
|
|
function isTrustedReviewComment(comment, allowedBotLogins = DEFAULT_ALLOWED_REVIEW_BOT_LOGINS) {
|
|
if (!comment?.body || !comment.body.includes(REVIEW_HEADING)) {
|
|
return false;
|
|
}
|
|
|
|
if (TRUSTED_AUTHOR_ASSOCIATIONS.has(comment.author_association)) {
|
|
return true;
|
|
}
|
|
|
|
const login = comment.user?.login;
|
|
const userType = comment.user?.type;
|
|
return Boolean(login && userType === 'Bot' && allowedBotLogins.includes(login));
|
|
}
|
|
|
|
function selectLatestTrustedReviewComment(comments, allowedBotLogins = DEFAULT_ALLOWED_REVIEW_BOT_LOGINS) {
|
|
let latest = null;
|
|
|
|
for (const comment of comments) {
|
|
if (isTrustedReviewComment(comment, allowedBotLogins)) {
|
|
latest = comment;
|
|
}
|
|
}
|
|
|
|
return latest;
|
|
}
|
|
|
|
async function findLatestTrustedReviewComment(owner, repo, prNumber, allowedBotLogins) {
|
|
let page = 1;
|
|
let latest = null;
|
|
|
|
while (true) {
|
|
const comments = await githubRequest(
|
|
`/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100&page=${page}`,
|
|
);
|
|
|
|
latest = selectLatestTrustedReviewComment(comments, allowedBotLogins) ?? latest;
|
|
|
|
if (comments.length < 100) {
|
|
return latest;
|
|
}
|
|
|
|
page += 1;
|
|
}
|
|
}
|
|
|
|
async function postComment(owner, repo, prNumber, body) {
|
|
await githubRequest(`/repos/${owner}/${repo}/issues/${prNumber}/comments`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const repoSlug = getRequiredEnv('GITHUB_REPOSITORY');
|
|
const [owner, repo] = repoSlug.split('/');
|
|
const prNumber = process.env.PR_NUMBER || JSON.parse(
|
|
fs.readFileSync(getRequiredEnv('GITHUB_EVENT_PATH'), 'utf8'),
|
|
).inputs?.pr_number;
|
|
|
|
if (!prNumber) {
|
|
throw new Error('PR_NUMBER or workflow_dispatch input pr_number is required');
|
|
}
|
|
|
|
const pr = await githubRequest(`/repos/${owner}/${repo}/pulls/${prNumber}`);
|
|
const allowedReviewBotLogins = parseAllowedReviewBotLogins();
|
|
|
|
if (pr.head.repo.full_name !== repoSlug) {
|
|
throw new Error(
|
|
'Auto-apply is only supported for PR branches that live in the base repository.',
|
|
);
|
|
}
|
|
|
|
const reviewComment = await findLatestTrustedReviewComment(
|
|
owner,
|
|
repo,
|
|
prNumber,
|
|
allowedReviewBotLogins,
|
|
);
|
|
if (!reviewComment || !reviewComment.body) {
|
|
throw new Error(
|
|
'No trusted Tessl skill review comment found on this PR. Run the review first.',
|
|
);
|
|
}
|
|
|
|
const optimizedFiles = extractOptimizedContent(reviewComment.body);
|
|
if (optimizedFiles.size === 0) {
|
|
throw new Error('No optimized SKILL.md content found in the latest Tessl review comment.');
|
|
}
|
|
|
|
const branch = pr.head.ref;
|
|
console.log(`Applying optimization to PR #${prNumber} on ${branch}`);
|
|
|
|
runGit(['fetch', 'origin', branch]);
|
|
runGit(['checkout', '-B', branch, `origin/${branch}`]);
|
|
|
|
for (const [filePath, content] of optimizedFiles.entries()) {
|
|
const resolved = ensureRepoRelative(filePath);
|
|
fs.writeFileSync(resolved, content);
|
|
console.log(`Updated ${filePath}`);
|
|
}
|
|
|
|
runGit(['config', 'user.name', 'tessl-skill-review[bot]']);
|
|
runGit(['config', 'user.email', 'skill-review[bot]@users.noreply.github.com']);
|
|
runGit(['add', ...optimizedFiles.keys()]);
|
|
|
|
let committed = true;
|
|
try {
|
|
runGit(['commit', '-m', 'Apply optimized SKILL.md from Tessl review']);
|
|
} catch (error) {
|
|
const output = `${error.stdout || ''}\n${error.stderr || ''}`;
|
|
if (output.includes('nothing to commit')) {
|
|
committed = false;
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (!committed) {
|
|
await postComment(
|
|
owner,
|
|
repo,
|
|
prNumber,
|
|
'⚠️ No changes to apply. The PR branch already matches the latest Tessl optimization.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
runGit(['push', 'origin', `HEAD:${branch}`]);
|
|
|
|
const shortSha = runGit(['rev-parse', '--short', 'HEAD']).trim();
|
|
const updatedFiles = [...optimizedFiles.keys()].map((item) => `\`${item}\``).join(', ');
|
|
const improvements = extractKeyImprovements(reviewComment.body);
|
|
|
|
let body = `✅ Applied optimized ${updatedFiles} (${shortSha}).`;
|
|
if (improvements.length > 0) {
|
|
body += '\n\n**What changed:**';
|
|
for (const item of improvements.slice(0, 3)) {
|
|
body += `\n- ${item}`;
|
|
}
|
|
}
|
|
|
|
await postComment(owner, repo, prNumber, body);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.stack : String(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_ALLOWED_REVIEW_BOT_LOGINS,
|
|
REVIEW_HEADING,
|
|
findLatestTrustedReviewComment,
|
|
isTrustedReviewComment,
|
|
parseAllowedReviewBotLogins,
|
|
selectLatestTrustedReviewComment,
|
|
};
|