-
Notifications
You must be signed in to change notification settings - Fork 432
safe_outputs: set git safe.directory via process-scoped env vars before bundle branch pinning #41830
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
base: main
Are you sure you want to change the base?
safe_outputs: set git safe.directory via process-scoped env vars before bundle branch pinning #41830
Changes from all commits
28e9b40
6ab65a8
9c75a57
40ab4f7
d9f4fa2
ea090f1
49d5020
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 |
|---|---|---|
|
|
@@ -194,6 +194,39 @@ function resolvePatchWorkspacePath(workspacePath) { | |
| return { success: true, absolutePath: resolved }; | ||
| } | ||
|
|
||
| /** | ||
| * Ensure the current git checkout path is trusted in this process context. | ||
| * Injects safe.directory via GIT_CONFIG_COUNT/KEY/VALUE env vars so that all | ||
| * subsequent git commands in this process inherit the trust without writing to | ||
| * ~/.gitconfig (no persistent global git config side effects). | ||
| * | ||
| * GIT_CONFIG_COUNT/KEY/VALUE is supported since git 2.31. | ||
| * | ||
| * @param {string} gitCwd | ||
| * @param {{ debug: (message: string) => void }} server | ||
| * @returns {void} | ||
| */ | ||
| function ensureSafeDirectoryTrust(gitCwd, server) { | ||
| if (!gitCwd) return; | ||
|
|
||
| // Check if gitCwd is already present in the injected env-var config to avoid | ||
| // duplicate entries when the handler is called more than once in the same process. | ||
| // The `|| 0` guard converts NaN (from a malformed pre-existing GIT_CONFIG_COUNT) to 0, | ||
| // preventing GIT_CONFIG_KEY_NaN/VALUE_NaN entries that would corrupt the env-var config chain. | ||
| const existingCount = parseInt(process.env.GIT_CONFIG_COUNT || "0", 10) || 0; | ||
| for (let i = 0; i < existingCount; i++) { | ||
| if (process.env[`GIT_CONFIG_KEY_${i}`] === "safe.directory" && process.env[`GIT_CONFIG_VALUE_${i}`] === gitCwd) { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| const idx = existingCount; | ||
| process.env.GIT_CONFIG_COUNT = String(existingCount + 1); | ||
| process.env[`GIT_CONFIG_KEY_${idx}`] = "safe.directory"; | ||
| process.env[`GIT_CONFIG_VALUE_${idx}`] = gitCwd; | ||
| server.debug(`Configured git safe.directory for bridge context: ${gitCwd}`); | ||
| } | ||
|
|
||
| /** | ||
| * Create handlers for safe output tools | ||
| * @param {Object} server - The MCP server instance for logging | ||
|
|
@@ -752,6 +785,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { | |
| // This prevents TOCTOU races where the agent flips the ref between patch and bundle | ||
| // generation, causing the two to represent different commit sets. | ||
| const gitCwd = repoCwd || process.env.GITHUB_WORKSPACE || process.cwd(); | ||
| ensureSafeDirectoryTrust(gitCwd, server); | ||
|
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. [/zoom-out] 💡 Consider a shared initialization guardCalling the guard once at the top of function createHandlers(server, appendSafeOutput, config = {}) {
// Guard once for the lifetime of this server instance.
ensureSafeDirectoryTrust(
config.repoCwd || process.env.GITHUB_WORKSPACE || process.cwd(),
server,
);
// ... rest of handler setup
}This eliminates per-call-site duplication and makes the protection opt-out rather than opt-in. @copilot please address this. |
||
| let pinnedSha; | ||
| try { | ||
| pinnedSha = execGitSync(["rev-parse", "--verify", `refs/heads/${entry.branch}^{commit}`], { cwd: gitCwd }) | ||
|
|
@@ -1196,6 +1230,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { | |
| // This prevents TOCTOU races where the agent flips the ref between patch and bundle | ||
| // generation, causing the two to represent different commit sets. | ||
| const pushGitCwd = repoCwd || process.env.GITHUB_WORKSPACE || process.cwd(); | ||
| ensureSafeDirectoryTrust(pushGitCwd, server); | ||
| let pushPinnedSha; | ||
| try { | ||
| pushPinnedSha = execGitSync(["rev-parse", "--verify", `refs/heads/${entry.branch}^{commit}`], { cwd: pushGitCwd }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -739,6 +739,39 @@ describe("safe_outputs_handlers", () => { | |
| expect(mockAppendSafeOutput).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("injects GITHUB_WORKSPACE into GIT_CONFIG env vars for safe.directory before branch pinning", async () => { | ||
| const prevCount = process.env.GIT_CONFIG_COUNT; | ||
| const prevKeys = Object.fromEntries(Object.entries(process.env).filter(([k]) => /^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(k))); | ||
| // Clear any pre-existing GIT_CONFIG_COUNT so the injection starts at index 0. | ||
| delete process.env.GIT_CONFIG_COUNT; | ||
| for (const key of Object.keys(prevKeys)) delete process.env[key]; | ||
| try { | ||
| await handlers.createPullRequestHandler({ | ||
| branch: "feature-branch", | ||
| title: "Test PR", | ||
| body: "Test description", | ||
| }); | ||
|
|
||
| const count = parseInt(process.env.GIT_CONFIG_COUNT || "0", 10); | ||
| const injected = []; | ||
| for (let i = 0; i < count; i++) { | ||
| if (process.env[`GIT_CONFIG_KEY_${i}`] === "safe.directory") { | ||
| injected.push(process.env[`GIT_CONFIG_VALUE_${i}`]); | ||
| } | ||
| } | ||
| expect(injected).toContain(testWorkspaceDir); | ||
| } finally { | ||
| // Restore original env state. | ||
| if (prevCount === undefined) delete process.env.GIT_CONFIG_COUNT; | ||
| else process.env.GIT_CONFIG_COUNT = prevCount; | ||
| // Remove any GIT_CONFIG_KEY/VALUE vars added during the test, then restore originals. | ||
| for (const key of Object.keys(process.env).filter(k => /^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(k))) { | ||
| delete process.env[key]; | ||
| } | ||
| for (const [key, value] of Object.entries(prevKeys)) process.env[key] = value; | ||
| } | ||
| }); | ||
|
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. The PR claims "no duplicate growth" but no test exercises the deduplication path — a future breakage would go undetected. 💡 Details and suggested testThe idempotency guarantee — that calling Add a test like: it("does not add duplicate safe.directory entries on repeated calls", async () => {
const testHome = path.join(testWorkspaceDir, "home-safe-directory-dedup");
fs.mkdirSync(testHome, { recursive: true });
const previousHome = process.env.HOME;
process.env.HOME = testHome;
try {
// First call
await handlers.createPullRequestHandler({ branch: "feature", title: "T", body: "B" });
// Second call
await handlers.createPullRequestHandler({ branch: "feature", title: "T", body: "B" });
let safeDirectories = [];
try {
safeDirectories = execSync("git config --global --get-all safe.directory", {
env: { ...process.env, HOME: testHome }, stdio: "pipe",
}).toString().split("\n").map(l => l.trim()).filter(Boolean);
} catch { /* no entries */ }
const occurrences = safeDirectories.filter(d => d === testWorkspaceDir);
expect(occurrences).toHaveLength(1);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
}
}); |
||
|
|
||
| it("should include helpful details in error response", async () => { | ||
| const args = { | ||
| branch: "test-branch", | ||
|
|
@@ -1208,6 +1241,37 @@ describe("safe_outputs_handlers", () => { | |
| expect(mockAppendSafeOutput).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("injects GITHUB_WORKSPACE into GIT_CONFIG env vars for safe.directory before push branch pinning", async () => { | ||
| const prevCount = process.env.GIT_CONFIG_COUNT; | ||
| const prevKeys = Object.fromEntries(Object.entries(process.env).filter(([k]) => /^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(k))); | ||
| // Clear any pre-existing GIT_CONFIG_COUNT so the injection starts at index 0. | ||
| delete process.env.GIT_CONFIG_COUNT; | ||
| for (const key of Object.keys(prevKeys)) delete process.env[key]; | ||
| try { | ||
| await handlers.pushToPullRequestBranchHandler({ | ||
| branch: "feature-branch", | ||
| }); | ||
|
|
||
| const count = parseInt(process.env.GIT_CONFIG_COUNT || "0", 10); | ||
| const injected = []; | ||
| for (let i = 0; i < count; i++) { | ||
| if (process.env[`GIT_CONFIG_KEY_${i}`] === "safe.directory") { | ||
| injected.push(process.env[`GIT_CONFIG_VALUE_${i}`]); | ||
| } | ||
| } | ||
| expect(injected).toContain(testWorkspaceDir); | ||
| } finally { | ||
| // Restore original env state. | ||
| if (prevCount === undefined) delete process.env.GIT_CONFIG_COUNT; | ||
| else process.env.GIT_CONFIG_COUNT = prevCount; | ||
| // Remove any GIT_CONFIG_KEY/VALUE vars added during the test, then restore originals. | ||
| for (const key of Object.keys(process.env).filter(k => /^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(k))) { | ||
| delete process.env[key]; | ||
| } | ||
| for (const [key, value] of Object.entries(prevKeys)) process.env[key] = value; | ||
| } | ||
| }); | ||
|
|
||
| it("should require explicit pull_request_number when push_to_pull_request_branch target is '*'", async () => { | ||
| const wildcardHandlers = createHandlers(mockServer, mockAppendSafeOutput, { | ||
| push_to_pull_request_branch: { | ||
|
|
||
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.
Silent failure degrades to
debugonly, leaving no breadcrumb when the config write itself is the root cause.💡 Details and suggested fix
When
git config --global --add safe.directoryfails (e.g., the bridge HOME has a read-only gitconfig or an unusable path), the catch block callsserver.debug(...)and returns silently. The immediately followingrev-parsethen fails with the original "detected dubious ownership" message — which says nothing about thesafe.directorywrite failure. An operator debugging a production drop will see the pinning error but have no trail connecting it to the failed fixup.Minimum fix: use a higher-severity log level so the failure surfaces without debug flags:
If this helper is deliberately best-effort, document that intent and consider whether the caller should at least know it didn't succeed.