-
Notifications
You must be signed in to change notification settings - Fork 435
Align safe-outputs bundle pre-check with post-apply file detection #41457
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
50759b0
db07f68
f2d5998
6cc5cdc
7de0fe7
ec2512b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -102,6 +102,23 @@ function parsePositiveInteger(value) { | |
| return Number.isInteger(parsed) && parsed > 0 ? parsed : null; | ||
| } | ||
|
|
||
| /** | ||
| * Uses git as the source of truth for the files modified by a fetched bundle ref. | ||
| * | ||
| * @param {{ getExecOutput: (command: string, args?: string[], options?: any) => Promise<{ stdout: string }> }} exec | ||
| * @param {Record<string, unknown>} gitOptions | ||
| * @param {string} rangeBaseRef | ||
| * @param {string} bundleRef | ||
| * @returns {Promise<string[]>} | ||
| */ | ||
| async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef) { | ||
| const bundleDiffResult = await exec.getExecOutput("git", ["diff", "--name-only", "--no-renames", `${rangeBaseRef}..${bundleRef}`], gitOptions); | ||
| return bundleDiffResult.stdout | ||
| .split("\n") | ||
| .map(f => f.trim()) | ||
| .filter(Boolean); | ||
| } | ||
|
|
||
| /** | ||
| * Main handler factory for push_to_pull_request_branch | ||
| * Returns a message handler function that processes individual push_to_pull_request_branch messages | ||
|
|
@@ -208,9 +225,9 @@ async function main(config = {}) { | |
| core.warning(`Bundle file path was provided but file is not present on disk: ${bundleFilePath}; falling back to patch transport`); | ||
| } | ||
|
|
||
| // Always require a patch file for policy enforcement. Bundle is used for apply-time | ||
| // transport, but allowed-files/protected-files checks must run on patch content | ||
| // (see validation block below that calls checkFileProtection on patchContent). | ||
| // Always require a patch file. The patch remains the preview/debug artifact and | ||
| // the first-pass validation input; bundle transport adds an authoritative | ||
| // pre-apply git diff check later after the bundle ref has been fetched. | ||
| if (!hasPatchFile) { | ||
| const msg = "No patch file found - cannot push without changes"; | ||
|
|
||
|
|
@@ -789,6 +806,32 @@ async function main(config = {}) { | |
| } | ||
| core.info(`Fetched bundle to ${bundleRef}`); | ||
|
|
||
| // SECURITY: Use git's own diff against the fetched bundle ref as the | ||
| // authoritative pre-apply file set for bundle transport. This keeps | ||
| // bundle pre-check and post-apply verification aligned even when the | ||
| // patch artifact under-detects files (for example, merge-resolution | ||
| // content preserved only by the bundle transport). | ||
| { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @copilot refactor to own function, add git integration tests
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 6cc5cdc: extracted the bundle pre-apply git diff into
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 6cc5cdc: |
||
| const bundleFiles = await getBundlePreApplyFiles(exec, baseGitOpts, rangeBaseRef, bundleRef); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unhandled git error from 💡 Why this matters / suggested fixBefore this PR, bundle transport never invoked Two options: Option A — pass async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef) {
const result = await exec.getExecOutput(
"git", ["diff", "--name-only", "--no-renames", `${rangeBaseRef}..${bundleRef}`],
{ ...gitOptions, ignoreReturnCode: true }
);
if (result.exitCode !== 0) {
core.warning(`Bundle pre-apply diff failed (rangeBaseRef=${rangeBaseRef}): ${result.stderr}`);
return [];
}
return result.stdout.split("\n").map(f => f.trim()).filter(Boolean);
}Option B — wrap the call in its own try/catch at the call site and log before falling through. Either way, the error must be surfaced as a named warning, not swallowed by the bundle transport catch. |
||
| if (bundleFiles.length > 0) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silent policy bypass when bundle diff returns empty: if 💡 Impact and suggested fixBefore this PR, bundle transport never called The guard should fail closed, not open. Minimum fix: emit an observable warning and document the skip rather than treating an empty diff as safe: const bundleFiles = await getBundlePreApplyFiles(exec, baseGitOpts, rangeBaseRef, bundleRef);
if (bundleFiles.length === 0) {
core.warning(`Pre-apply bundle verification: git diff returned no paths (rangeBaseRef=${rangeBaseRef}); check skipped`);
} else {
core.info(`Pre-apply bundle verification: ${bundleFiles.length} file(s) detected from bundle transport`);
const bundleProtection = checkFileProtectionPostApply(bundleFiles, config);
// deny/fallback handling unchanged
}Ideally also add a test asserting the empty-diff case logs a warning and does not silently succeed.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnose] When 💡 Suggestionconst bundleFiles = await getBundlePreApplyFiles(exec, baseGitOpts, rangeBaseRef, bundleRef);
if (bundleFiles.length === 0) {
core.debug("Pre-apply bundle verification: git diff returned no files; skipping protection check");
} else {
core.info(`Pre-apply bundle verification: ${bundleFiles.length} file(s) detected from bundle transport`);
// ... existing protection logic
}This also makes it easier to diagnose future false-negatives where the diff range is wrong and git silently returns nothing. |
||
| core.info(`Pre-apply bundle verification: ${bundleFiles.length} file(s) detected from bundle transport`); | ||
| const bundleProtection = checkFileProtectionPostApply(bundleFiles, config); | ||
| if (bundleProtection.action === "deny") { | ||
| const filesStr = bundleProtection.files.join(", "); | ||
| const msg = | ||
| bundleProtection.source === "post-apply" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnose] The 💡 Suggestion// bundleProtection.source reflects which check fired:
// "post-apply" → file not in allowed_files list
// "protected" → file matched a protected-files rule
const msg =
bundleProtection.source === "post-apply"
? `Cannot push...`
: `Cannot push (protected files)...`; |
||
| ? `Cannot push to pull request branch: bundle modifies files outside the allowed-files list (${filesStr}). Add the files to the allowed-files configuration field or remove them from the bundle.` | ||
| : `Cannot push to pull request branch: bundle modifies protected files (${filesStr}). Add them to the allowed-files configuration field or set protected-files: fallback-to-issue to create a review issue instead.`; | ||
| core.error(msg); | ||
| return { success: false, error: msg }; | ||
| } | ||
| if (bundleProtection.action === "fallback") { | ||
| core.warning(`Protected file protection triggered (fallback-to-issue): ${bundleProtection.files.join(", ")}. Will create review issue instead of pushing.`); | ||
| return await createProtectedFilesFallbackIssue(bundleProtection.files); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Point the checked-out branch at the bundle tip directly. In shallow | ||
| // checkouts, merge --ff-only can fail to discover the ancestry even | ||
| // when the bundle tip is based on the current branch tip and the | ||
|
|
@@ -1300,4 +1343,4 @@ async function main(config = {}) { | |
| }; | ||
| } | ||
|
|
||
| module.exports = { main, HANDLER_TYPE }; | ||
| module.exports = { main, HANDLER_TYPE, getBundlePreApplyFiles }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { describe, it, expect, afterEach, vi } from "vitest"; | ||
| import { createRequire } from "module"; | ||
| import fs from "fs"; | ||
| import os from "os"; | ||
| import path from "path"; | ||
| import { spawnSync } from "child_process"; | ||
|
|
||
| const require = createRequire(import.meta.url); | ||
| const { getBundlePreApplyFiles } = require("./push_to_pull_request_branch.cjs"); | ||
|
|
||
| global.core = { | ||
| debug: vi.fn(), | ||
| error: vi.fn(), | ||
| info: vi.fn(), | ||
| warning: vi.fn(), | ||
| }; | ||
|
|
||
| function execGit(args, options = {}) { | ||
| const result = spawnSync("git", args, { | ||
| encoding: "utf8", | ||
| ...options, | ||
| }); | ||
| if (result.error) { | ||
| throw result.error; | ||
| } | ||
| if (result.status !== 0 && !options.allowFailure) { | ||
| throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| function createRepo(prefix) { | ||
| const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | ||
| execGit(["init"], { cwd: repoDir }); | ||
| execGit(["config", "user.name", "Test User"], { cwd: repoDir }); | ||
| execGit(["config", "user.email", "test@example.com"], { cwd: repoDir }); | ||
| return repoDir; | ||
| } | ||
|
|
||
| function writeRepoFile(repoDir, relativePath, content) { | ||
| const fullPath = path.join(repoDir, relativePath); | ||
| fs.mkdirSync(path.dirname(fullPath), { recursive: true }); | ||
| fs.writeFileSync(fullPath, content); | ||
| } | ||
|
|
||
| function createExecApi(cwd) { | ||
| return { | ||
| async getExecOutput(command, args = [], options = {}) { | ||
| if (command !== "git") { | ||
| throw new Error(`unexpected command: ${command}`); | ||
| } | ||
| const result = execGit(args, { cwd, allowFailure: true }); | ||
| if (result.status !== 0 && !options.ignoreReturnCode) { | ||
| throw new Error(result.stderr || result.stdout); | ||
| } | ||
| return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr }; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function fetchBaseCommit(targetRepo, sourceRepo, baseSha, branchName) { | ||
| execGit(["remote", "add", "origin", sourceRepo], { cwd: targetRepo }); | ||
| execGit(["fetch", "origin", baseSha], { cwd: targetRepo }); | ||
| execGit(["checkout", "-b", branchName, "FETCH_HEAD"], { cwd: targetRepo }); | ||
| } | ||
|
|
||
| describe("push_to_pull_request_branch bundle integration", () => { | ||
| const tempDirs = []; | ||
|
|
||
| afterEach(() => { | ||
| for (const tempDir of tempDirs.splice(0)) { | ||
| fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| } | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it("lists files from a fetched bundle before applying it", async () => { | ||
| const branchName = "autoloop/simple-bundle"; | ||
| const sourceRepo = createRepo("push-pr-bundle-source-"); | ||
| const targetRepo = createRepo("push-pr-bundle-target-"); | ||
| tempDirs.push(sourceRepo, targetRepo); | ||
|
|
||
| writeRepoFile(sourceRepo, "README.md", "base\n"); | ||
| execGit(["add", "README.md"], { cwd: sourceRepo }); | ||
| execGit(["commit", "-m", "base"], { cwd: sourceRepo }); | ||
| execGit(["branch", "-M", "main"], { cwd: sourceRepo }); | ||
| const baseSha = execGit(["rev-parse", "HEAD"], { cwd: sourceRepo }).stdout.trim(); | ||
|
|
||
| execGit(["checkout", "-b", branchName], { cwd: sourceRepo }); | ||
| writeRepoFile(sourceRepo, ".changeset/fix.md", "patch\n"); | ||
| writeRepoFile(sourceRepo, "docs/guide.md", "guide\n"); | ||
| execGit(["add", ".changeset/fix.md", "docs/guide.md"], { cwd: sourceRepo }); | ||
| execGit(["commit", "-m", "bundle change"], { cwd: sourceRepo }); | ||
|
|
||
| const bundlePath = path.join(sourceRepo, "bundle.bundle"); | ||
| execGit(["bundle", "create", bundlePath, `refs/heads/${branchName}`], { cwd: sourceRepo }); | ||
|
|
||
| fetchBaseCommit(targetRepo, sourceRepo, baseSha, branchName); | ||
| const bundleRef = "refs/bundles/test-simple-bundle"; | ||
| execGit(["fetch", bundlePath, `refs/heads/${branchName}:${bundleRef}`], { cwd: targetRepo }); | ||
|
|
||
| const actualFiles = await getBundlePreApplyFiles(createExecApi(targetRepo), {}, baseSha, bundleRef); | ||
|
|
||
| expect(actualFiles.sort()).toEqual([".changeset/fix.md", "docs/guide.md"]); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The integration tests exercise 💡 SuggestionAdd a third integration test that:
This would be a direct regression test for the root-cause scenario described in the PR. |
||
| }); | ||
|
|
||
| it("includes files introduced through merge-commit bundle history", async () => { | ||
| const branchName = "autoloop/merge-bundle"; | ||
| const sourceRepo = createRepo("push-pr-merge-source-"); | ||
| const targetRepo = createRepo("push-pr-merge-target-"); | ||
| tempDirs.push(sourceRepo, targetRepo); | ||
|
|
||
| writeRepoFile(sourceRepo, "README.md", "base\n"); | ||
| execGit(["add", "README.md"], { cwd: sourceRepo }); | ||
| execGit(["commit", "-m", "base"], { cwd: sourceRepo }); | ||
| execGit(["branch", "-M", "main"], { cwd: sourceRepo }); | ||
| const baseSha = execGit(["rev-parse", "HEAD"], { cwd: sourceRepo }).stdout.trim(); | ||
|
|
||
| execGit(["checkout", "-b", "feature"], { cwd: sourceRepo }); | ||
| writeRepoFile(sourceRepo, "feature.txt", "feature branch change\n"); | ||
| execGit(["add", "feature.txt"], { cwd: sourceRepo }); | ||
| execGit(["commit", "-m", "feature commit"], { cwd: sourceRepo }); | ||
|
|
||
| execGit(["checkout", "main"], { cwd: sourceRepo }); | ||
| writeRepoFile(sourceRepo, "main.txt", "main branch change\n"); | ||
| execGit(["add", "main.txt"], { cwd: sourceRepo }); | ||
| execGit(["commit", "-m", "main commit"], { cwd: sourceRepo }); | ||
| execGit(["merge", "--no-ff", "feature", "-m", "merge feature"], { cwd: sourceRepo }); | ||
| execGit(["checkout", "-b", branchName], { cwd: sourceRepo }); | ||
|
|
||
| const bundlePath = path.join(sourceRepo, "merge.bundle"); | ||
| execGit(["bundle", "create", bundlePath, `refs/heads/${branchName}`], { cwd: sourceRepo }); | ||
|
|
||
| fetchBaseCommit(targetRepo, sourceRepo, baseSha, branchName); | ||
| const bundleRef = "refs/bundles/test-merge-bundle"; | ||
| execGit(["fetch", bundlePath, `refs/heads/${branchName}:${bundleRef}`], { cwd: targetRepo }); | ||
|
|
||
| const actualFiles = await getBundlePreApplyFiles(createExecApi(targetRepo), {}, baseSha, bundleRef); | ||
|
|
||
| expect(actualFiles.sort()).toEqual(["feature.txt", "main.txt"]); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1532,6 +1532,70 @@ index 0000000..abc1234 | |
| } | ||
| }); | ||
|
|
||
| it("should use authoritative bundle file detection before apply and match post-apply verification", async () => { | ||
| const bundlePath = canonicalBundlePath("feature-branch"); | ||
| const patchPath = createPatchFile( | ||
| "feature-branch", | ||
| `From abc123 Mon Sep 17 00:00:00 2001 | ||
| From: Test Author <test@example.com> | ||
| Date: Mon, 1 Jan 2024 00:00:00 +0000 | ||
| Subject: [PATCH] Test commit | ||
|
|
||
| diff --git a/.changeset/patch-fix.md b/.changeset/patch-fix.md | ||
| new file mode 100644 | ||
| index 0000000..abc1234 | ||
| --- /dev/null | ||
| +++ b/.changeset/patch-fix.md | ||
| @@ -0,0 +1 @@ | ||
| +content | ||
| -- | ||
| 2.34.1 | ||
| ` | ||
| ); | ||
| fs.writeFileSync(bundlePath, "bundle content"); | ||
|
|
||
| const pushSignedCommitsModule = require("./push_signed_commits.cjs"); | ||
| const pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue("bundle-tip"); | ||
|
|
||
| try { | ||
| const actualFiles = [".changeset/patch-fix.md", "pkg/workflow/pi_byok_env_passthrough_integration_test.go", "pkg/workflow/pi_engine.go", "pkg/workflow/pi_engine_test.go"]; | ||
| mockExec.getExecOutput.mockImplementation((cmd, args, options) => { | ||
| if (cmd === "git" && args[0] === "ls-remote") { | ||
| return Promise.resolve({ exitCode: 0, stdout: "remote-head\trefs/heads/feature-branch\n", stderr: "" }); | ||
| } | ||
| if (cmd === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { | ||
| return Promise.resolve({ exitCode: 0, stdout: "remote-head\n", stderr: "" }); | ||
| } | ||
| if (cmd === "git" && args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { | ||
| return Promise.resolve({ exitCode: 0, stdout: "false\n", stderr: "" }); | ||
| } | ||
| if (cmd === "git" && args[0] === "fetch" && args[1] === bundlePath && options && options.ignoreReturnCode) { | ||
| return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); | ||
| } | ||
| if (cmd === "git" && args[0] === "diff" && args[1] === "--name-only" && args[2] === "--no-renames") { | ||
| return Promise.resolve({ exitCode: 0, stdout: `${actualFiles.join("\n")}\n`, stderr: "" }); | ||
| } | ||
| if (cmd === "git" && args[0] === "rev-list") { | ||
| return Promise.resolve({ exitCode: 0, stdout: "2\n", stderr: "" }); | ||
| } | ||
| return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The catch-all mock ( 💡 SuggestionReplace the catch-all with a rejecting fallback so unexpected calls surface immediately: return Promise.reject(new Error(`Unexpected getExecOutput call: ${cmd} ${JSON.stringify(args)}`));This follows the principle that test doubles should be as strict as the real contract. |
||
| }); | ||
|
|
||
| const module = await loadModule(); | ||
| const handler = await module.main({ allowed_files: [".changeset/**", "pkg/workflow/**"] }); | ||
| const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); | ||
|
|
||
| expect(result.success).toBe(true); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] This test only covers the happy path (all 4 files in scope → 💡 Missing test sketchit("should deny push when bundle contains files outside allowed_files", async () => {
// same setup, but actualFiles includes a file not matching allowed_files globs
const actualFiles = [".changeset/patch-fix.md", "secrets/private.key"];
mockExec.getExecOutput.mockImplementation((cmd, args) => {
if (cmd === "git" && args[0] === "diff" && args[1] === "--name-only") {
return Promise.resolve({ exitCode: 0, stdout: actualFiles.join("\n") + "\n", stderr: "" });
}
// ... other mocks
});
const module = await loadModule();
const handler = await module.main({ allowed_files: [".changeset/**"] });
const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {});
expect(result.success).toBe(false);
expect(result.error).toContain("bundle modifies files outside the allowed-files list");
});Without this, the security enforcement in the new bundle pre-check path has no regression test. |
||
| expect(mockCore.info).toHaveBeenCalledWith("Pre-apply bundle verification: 4 file(s) detected from bundle transport"); | ||
|
|
||
| const diffCalls = mockExec.getExecOutput.mock.calls.filter(([, args]) => Array.isArray(args) && args[0] === "diff" && args[1] === "--name-only" && args[2] === "--no-renames"); | ||
| expect(diffCalls.map(([, args]) => args[3])).toContain("remote-head..refs/bundles/push-feature-branch"); | ||
| expect(diffCalls.map(([, args]) => args[3])).toContain("remote-head..HEAD"); | ||
| } finally { | ||
| pushSignedSpy.mockRestore(); | ||
| } | ||
| }); | ||
|
|
||
| it("should use sanitized branch name (not agent-supplied message.branch) in bundle fetch refspec", async () => { | ||
| // The agent may supply a message.branch value; the bundle fetch must use the | ||
| // sanitized branchName from the GitHub API — never the raw agent input. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/diagnose]
getBundlePreApplyFilescallsgetExecOutputwithoutignoreReturnCode: true, so any git failure (e.g. a ref that unexpectedly does not exist) will throw an unhandled exception rather than produce a clear diagnostic message.💡 Suggestion
Consider wrapping the call with
ignoreReturnCode: trueand surfacing git error output explicitly:This is especially valuable because refs should always be present at this point in the flow, so a failure here indicates an unexpected state worth flagging loudly.