Repository navigation
Scope PR push file policy to agent commits #67241
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 2 commits
779ae50
b3ebeca
82dbe26
79eaa70
dcbda10
8d4785c
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 |
|---|---|---|
|
|
@@ -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}`; | ||
| const agentFiles = execGitSync(["log", "--name-only", "--pretty=format:", `${incrementalBase}..${pushPinnedSha}`, "--"], { cwd: pushGitCwd }) | ||
|
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. Updated the commit walk with |
||
| .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, | ||
| }), | ||
| }, | ||
|
|
@@ -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)}`); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"); | ||
|
|
@@ -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()); | ||
|
|
@@ -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
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. The regression now adds |
||
|
|
||
| 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 { | ||
|
|
@@ -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"], | ||
|
|
@@ -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 { | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
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.
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 afterprHeadBaselineis 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.
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.
Computed
incrementalBaseRefonce after resolving the PR-head baseline and reused it for merge detection and allowed-file inspection. Fixed in 79eaa70.