Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
31 changes: 31 additions & 0 deletions actions/setup/js/push_to_pull_request_branch.integration.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { spawnSync } from "child_process";

const require = createRequire(import.meta.url);
const { getBundlePreApplyFiles } = require("./push_to_pull_request_branch.cjs");
const { checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs");

global.core = {
debug: vi.fn(),
Expand Down Expand Up @@ -104,6 +105,36 @@ describe("push_to_pull_request_branch bundle integration", () => {
expect(actualFiles.sort()).toEqual([".changeset/fix.md", "docs/guide.md"]);
});

it("checks only bundle commits added after an existing PR head", async () => {
const branchName = "feature/existing-pr";
const sourceRepo = createRepo("push-pr-existing-source-");
const targetRepo = createRepo("push-pr-existing-target-");
tempDirs.push(sourceRepo, targetRepo);

writeRepoFile(sourceRepo, "README.md", "base\n");
execGit(["add", "README.md"], { cwd: sourceRepo });
execGit(["commit", "-m", "base"], { cwd: sourceRepo });
execGit(["checkout", "-b", branchName], { cwd: sourceRepo });
writeRepoFile(sourceRepo, "tools/__pycache__/author.pyc", "author file\n");
execGit(["add", "tools/__pycache__/author.pyc"], { cwd: sourceRepo });
execGit(["commit", "-m", "author change"], { cwd: sourceRepo });
const existingHead = execGit(["rev-parse", "HEAD"], { cwd: sourceRepo }).stdout.trim();

writeRepoFile(sourceRepo, "config/agent.json", "{}\n");
execGit(["add", "config/agent.json"], { cwd: sourceRepo });
execGit(["commit", "-m", "agent change"], { cwd: sourceRepo });
const bundlePath = path.join(sourceRepo, "incremental.bundle");
execGit(["bundle", "create", bundlePath, `refs/heads/${branchName}`], { cwd: sourceRepo });

fetchBaseCommit(targetRepo, sourceRepo, existingHead, branchName);
const bundleRef = "refs/bundles/test-existing-pr";
execGit(["fetch", bundlePath, `refs/heads/${branchName}:${bundleRef}`], { cwd: targetRepo });
const files = await getBundlePreApplyFiles(createExecApi(targetRepo), {}, existingHead, bundleRef);

expect(files).toEqual(["config/agent.json"]);
expect(checkFileProtectionPostApply(files, { allowed_files: ["**/*.json"] }).action).toBe("allow");
});

it("fetches a HEAD-only bundle after its named branch ref is absent", () => {
const branchName = "autoloop/head-only-bundle";
const sourceRepo = createRepo("push-pr-head-only-source-");
Expand Down
36 changes: 15 additions & 21 deletions actions/setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1595,49 +1595,43 @@ function createHandlers(server, appendSafeOutput, config = {}) {
pushPinnedSha = null;
}

// Full-branch allowed_files check: validate that ALL commits on the PR branch
// (relative to origin/baseBranch) only touch files permitted by allowed_files.
// The incremental patch check at apply-time only inspects the net diff between
// origin/<branch> and the local branch tip; this catches disallowed files that
// appear in earlier commits on the branch (e.g. a Copilot branch that also
// modified .github/workflows/**) and returns an actionable error to the agent
// before any transport artifacts are generated.
// Validate the commits added after the existing PR head, not author-owned
// commits already on the branch. Inspect each commit rather than only the net
// diff so a disallowed file changed and reverted by the agent is still caught.
if (Array.isArray(pushConfig.allowed_files) && pushConfig.allowed_files.length > 0) {
try {
// Use the pinned SHA as the range head to avoid any TOCTOU window between
// the time the SHA was recorded and the time of the git log query. If no
// pinned SHA is available (e.g. non-bundle path), skip the check so we do
// not race against a mutable ref; the apply-time check still enforces policy.
// Use the pinned SHA to avoid racing a mutable local branch ref.
if (!pushPinnedSha) {
server.debug("Full-branch allowed-files check skipped: branch SHA not pinned (non-bundle path)");
server.debug("Incremental allowed-files check skipped: branch SHA not pinned (non-bundle path)");
} else {
const branchHistoryFiles = execGitSync(["log", "--name-only", "--pretty=format:", `origin/${baseBranch}..${pushPinnedSha}`, "--"], { cwd: pushGitCwd })
const incrementalBase = prHeadBaseline?.sha || prHeadBaseline?.ref || `refs/remotes/origin/${entry.branch}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this fallback expression (prHeadBaseline?.sha || prHeadBaseline?.ref || refs/remotes/origin/${entry.branch}) is duplicated verbatim at line 1559 for the merge-detection check. Consider computing it once into a local (e.g. incrementalBaseRef) right after prHeadBaseline is resolved and reusing it at both call sites, so the two copies can't drift if the baseline-resolution semantics change later. Not blocking.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Computed incrementalBaseRef once after resolving the PR-head baseline and reused it for merge detection and allowed-file inspection. Fixed in 79eaa70.

const agentFiles = execGitSync(["log", "--name-only", "--pretty=format:", `${incrementalBase}..${pushPinnedSha}`, "--"], { cwd: pushGitCwd })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the commit walk with --ancestry-path so upstream-only commits merged from the base branch are excluded, and added a merge regression. Fixed in 79eaa70.

.toString()
.split("\n")
.map(s => s.trim())
.filter(Boolean);

if (branchHistoryFiles.length > 0) {
if (agentFiles.length > 0) {
const allowedPatterns = pushConfig.allowed_files.map(p => globPatternToRegex(p));
// Files matching excluded_files are intentionally exempt: they will be stripped
// from the patch at generation time via :(exclude) pathspecs, so they won't be
// present in the final changeset applied to the branch.
const excludedPatterns = Array.isArray(pushConfig.excluded_files) ? pushConfig.excluded_files.map(p => globPatternToRegex(p)) : [];
const uniqueFiles = [...new Set(branchHistoryFiles)];
const uniqueFiles = [...new Set(agentFiles)];
const disallowedFiles = uniqueFiles.filter(f => !allowedPatterns.some(re => re.test(f)) && !excludedPatterns.some(re => re.test(f)));

if (disallowedFiles.length > 0) {
const sample = disallowedFiles.slice(0, 5);
const remaining = disallowedFiles.length - sample.length;
const filesStr = remaining > 0 ? `${sample.join(", ")} (+${remaining} more)` : sample.join(", ");
server.debug(`Full-branch allowed-files check failed: ${filesStr}`);
server.debug(`Incremental allowed-files check failed: ${filesStr}`);
return {
content: [
{
type: "text",
text: JSON.stringify({
result: "error",
error: `Cannot push to pull request branch: the branch '${entry.branch}' history contains commits that modify files outside the allowed-files configuration: ${filesStr}. Remove the disallowed file changes from your commits and retry, or update the allowed-files configuration to include these files.`,
error: `Cannot push to pull request branch: the agent commits on '${entry.branch}' modify files outside the allowed-files configuration: ${filesStr}. Remove the disallowed file changes from your commits and retry, or update the allowed-files configuration to include these files.`,
disallowed_files: disallowedFiles,
}),
},
Expand All @@ -1647,12 +1641,12 @@ function createHandlers(server, appendSafeOutput, config = {}) {
}
}
}
} catch (fullBranchCheckError) {
// Non-fatal: if origin/baseBranch is not available locally or git fails,
// skip the full-branch check and continue. The apply-time policy check in
} catch (incrementalCheckError) {
// Non-fatal: if the PR head is not available locally or git fails,
// skip this check and continue. The apply-time policy check in
// push_to_pull_request_branch.cjs will still enforce allowed_files against
// the incremental patch content.
server.debug(`Full-branch allowed-files check skipped (non-fatal): ${getErrorMessage(fullBranchCheckError)}`);
server.debug(`Incremental allowed-files check skipped (non-fatal): ${getErrorMessage(incrementalCheckError)}`);
}
}

Expand Down
60 changes: 44 additions & 16 deletions actions/setup/js/safe_outputs_handlers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2538,11 +2538,9 @@ describe("safe_outputs_handlers", () => {
}
});

describe("full-branch allowed_files check", () => {
describe("incremental allowed_files check", () => {
/**
* Creates a git repo whose history contains a file that violates `allowed_files`
* (a .js file when only .md is allowed). Sets up `origin/main` as the tracking ref
* so `execGitSync` can compute the range `origin/main..<branch>`.
* Creates a PR branch with an author-owned .js file and an agent-owned .md file.
*/
function createRepoWithDisallowedHistoryFile() {
const repoDir = path.join(testWorkspaceDir, "allowed-files-repo");
Expand Down Expand Up @@ -2570,15 +2568,21 @@ describe("safe_outputs_handlers", () => {
fs.writeFileSync(path.join(repoDir, "script.js"), "console.log('hi');\n");
execSync("git add script.js", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m 'add script'", { cwd: repoDir, stdio: "pipe" });
const prHeadSha = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();

// Set up origin/main tracking ref
fs.writeFileSync(path.join(repoDir, "agent.md"), "agent change\n");
execSync("git add agent.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m 'agent change'", { cwd: repoDir, stdio: "pipe" });

// Set up tracking refs at the original PR head.
execSync("git remote add origin https://github.com/test-owner/test-repo.git", { cwd: repoDir, stdio: "pipe" });
execSync(`git update-ref refs/remotes/origin/main ${mainSha}`, { cwd: repoDir, stdio: "pipe" });
execSync(`git update-ref refs/remotes/origin/feature/work ${prHeadSha}`, { cwd: repoDir, stdio: "pipe" });

return { repoDir };
}

it("returns isError when branch history contains a file outside allowed_files", async () => {
it("does not reject author-owned files outside allowed_files", async () => {
let repoDir;
try {
({ repoDir } = createRepoWithDisallowedHistoryFile());
Expand All @@ -2598,21 +2602,42 @@ describe("safe_outputs_handlers", () => {
try {
const result = await localHandlers.pushToPullRequestBranchHandler({ branch: "feature/work" });

expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining("Generating incremental patch for push_to_pull_request_branch"));
if (result.isError) {
const data = JSON.parse(result.content[0].text);
expect(data.error).not.toContain("allowed-files configuration");
expect(data.disallowed_files).toBeUndefined();
}
} finally {
delete process.env.GITHUB_BASE_REF;
process.env.GITHUB_WORKSPACE = testWorkspaceDir;
}
});

it("rejects disallowed files introduced after the existing PR head", async () => {
const { repoDir } = createRepoWithDisallowedHistoryFile();
fs.writeFileSync(path.join(repoDir, "agent.js"), "console.log('agent');\n");
execSync("git add agent.js", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m 'agent script'", { cwd: repoDir, stdio: "pipe" });
Comment on lines +2617 to +2621

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regression now adds agent.js and removes it in a second commit before invoking the handler, proving per-commit detection survives a net-zero change. Fixed in 79eaa70.


process.env.GITHUB_BASE_REF = "main";
process.env.GITHUB_WORKSPACE = repoDir;
const localHandlers = createHandlers(mockServer, mockAppendSafeOutput, {
push_to_pull_request_branch: { allowed_files: ["*.md"] },
});
try {
const result = await localHandlers.pushToPullRequestBranchHandler({ branch: "feature/work" });
expect(result.isError).toBe(true);
const data = JSON.parse(result.content[0].text);
expect(data.result).toBe("error");
expect(data.error).toContain("allowed-files");
expect(data.disallowed_files).toBeDefined();
expect(data.disallowed_files).toContain("script.js");
// Safe output must NOT have been recorded for a disallowed branch
expect(data.disallowed_files).toEqual(["agent.js"]);
expect(mockAppendSafeOutput).not.toHaveBeenCalled();
} finally {
delete process.env.GITHUB_BASE_REF;
process.env.GITHUB_WORKSPACE = testWorkspaceDir;
}
});

it("does not block when all branch history files are within allowed_files", async () => {
it("does not block when agent files are within allowed_files", async () => {
const repoDir = path.join(testWorkspaceDir, "allowed-files-ok-repo");
fs.mkdirSync(repoDir, { recursive: true });
try {
Expand Down Expand Up @@ -2671,7 +2696,10 @@ describe("safe_outputs_handlers", () => {

process.env.GITHUB_BASE_REF = "main";
process.env.GITHUB_WORKSPACE = repoDir;
// excluded_files exempts .js files, so script.js should not trigger the error
fs.writeFileSync(path.join(repoDir, "agent.js"), "console.log('agent');\n");
execSync("git add agent.js", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m 'agent script'", { cwd: repoDir, stdio: "pipe" });
// excluded_files exempts the agent's .js file.
const localHandlers = createHandlers(mockServer, mockAppendSafeOutput, {
push_to_pull_request_branch: {
allowed_files: ["*.md"],
Expand All @@ -2694,7 +2722,7 @@ describe("safe_outputs_handlers", () => {
}
});

it("is non-fatal when origin/baseBranch is not available (continues without blocking)", async () => {
it("is non-fatal when the remote PR head is not available (continues without blocking)", async () => {
const repoDir = path.join(testWorkspaceDir, "no-origin-repo");
fs.mkdirSync(repoDir, { recursive: true });
try {
Expand All @@ -2707,11 +2735,11 @@ describe("safe_outputs_handlers", () => {
execSync("git commit -m 'base'", { cwd: repoDir, stdio: "pipe" });

execSync("git checkout -b feature/work", { cwd: repoDir, stdio: "pipe" });
// Add a disallowed file — but since origin/main is absent the check must be skipped
// Add a disallowed file — but since the remote PR head is absent the check must be skipped
fs.writeFileSync(path.join(repoDir, "script.js"), "// disallowed\n");
execSync("git add script.js", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m 'disallowed file'", { cwd: repoDir, stdio: "pipe" });
// No remote / no origin/main tracking ref
// No remote / no remote PR head tracking ref
} catch {
return;
}
Expand Down
Loading