mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +08:00
fix(mcp): detect binary files by content instead of extension allowlist (#1633)
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Decides whether a file has to be committed as a base64 blob instead of being
|
||||||
|
* inlined in the Git tree as UTF-8 text.
|
||||||
|
*
|
||||||
|
* Inlining is only safe for content that survives a UTF-8 decode untouched;
|
||||||
|
* anything else gets its invalid bytes replaced during the decode, which
|
||||||
|
* silently corrupts the committed file. A NUL byte is treated as binary for the
|
||||||
|
* same reason Git does it: no text file carries one, and it is the cheapest
|
||||||
|
* signal available.
|
||||||
|
*/
|
||||||
|
export function isBinaryContent(content: Buffer): boolean {
|
||||||
|
if (content.includes(0)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(content);
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { resolve } from "path";
|
|||||||
import { constants } from "fs";
|
import { constants } from "fs";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
|
import { isBinaryContent } from "./binary-detection";
|
||||||
import { validatePathWithinRepo } from "./path-validation";
|
import { validatePathWithinRepo } from "./path-validation";
|
||||||
import { updateGitReference } from "./update-git-reference";
|
import { updateGitReference } from "./update-git-reference";
|
||||||
|
|
||||||
@@ -258,17 +259,14 @@ server.tool(
|
|||||||
// Get the proper file mode based on file permissions
|
// Get the proper file mode based on file permissions
|
||||||
const fileMode = await getFileMode(fullPath);
|
const fileMode = await getFileMode(fullPath);
|
||||||
|
|
||||||
// Check if file is binary (images, etc.)
|
// Check if the file is binary by inspecting its contents. An
|
||||||
const isBinaryFile =
|
// extension allowlist used to decide this, which corrupted every
|
||||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
// binary type that wasn't on the list.
|
||||||
relativePath,
|
const fileContent = await readFile(fullPath);
|
||||||
);
|
|
||||||
|
|
||||||
if (isBinaryFile) {
|
if (isBinaryContent(fileContent)) {
|
||||||
// For binary files, create a blob first using the Blobs API
|
// For binary files, create a blob first using the Blobs API
|
||||||
const binaryContent = await readFile(fullPath);
|
// (supports the encoding parameter)
|
||||||
|
|
||||||
// Create blob using Blobs API (supports encoding parameter)
|
|
||||||
const blobUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs`;
|
const blobUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs`;
|
||||||
const blobResponse = await fetch(blobUrl, {
|
const blobResponse = await fetch(blobUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -279,7 +277,7 @@ server.tool(
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content: binaryContent.toString("base64"),
|
content: fileContent.toString("base64"),
|
||||||
encoding: "base64",
|
encoding: "base64",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -302,12 +300,11 @@ server.tool(
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// For text files, include content directly in tree
|
// For text files, include content directly in tree
|
||||||
const content = await readFile(fullPath, "utf-8");
|
|
||||||
return {
|
return {
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
mode: fileMode,
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
content: content,
|
content: fileContent.toString("utf-8"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { isBinaryContent } from "../src/mcp/binary-detection";
|
||||||
|
|
||||||
|
describe("isBinaryContent", () => {
|
||||||
|
describe("text content", () => {
|
||||||
|
it("treats ASCII as text", () => {
|
||||||
|
expect(isBinaryContent(Buffer.from("hello world\n"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats multibyte UTF-8 as text", () => {
|
||||||
|
expect(isBinaryContent(Buffer.from("café — 日本語 🎉\n"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an empty file as text", () => {
|
||||||
|
expect(isBinaryContent(Buffer.from(""))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats CRLF and tabs as text", () => {
|
||||||
|
expect(isBinaryContent(Buffer.from("a\tb\r\nc\r\n"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("binary content", () => {
|
||||||
|
// The extensions below are the ones the previous allowlist covered, so
|
||||||
|
// these files were already committed correctly.
|
||||||
|
it("detects PNG", () => {
|
||||||
|
expect(
|
||||||
|
isBinaryContent(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// These are the regression cases: binary formats that were not on the
|
||||||
|
// allowlist and got decoded as UTF-8, corrupting the committed bytes.
|
||||||
|
it("detects BMP", () => {
|
||||||
|
expect(
|
||||||
|
isBinaryContent(Buffer.from([0x42, 0x4d, 0x36, 0x00, 0x00, 0x00])),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects SQLite databases", () => {
|
||||||
|
expect(isBinaryContent(Buffer.from("SQLite format 3\0", "binary"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects WebAssembly modules", () => {
|
||||||
|
expect(
|
||||||
|
isBinaryContent(Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00])),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects arbitrary invalid UTF-8 without NUL bytes", () => {
|
||||||
|
// Lone continuation bytes: no NUL, but not decodable as UTF-8 either.
|
||||||
|
expect(isBinaryContent(Buffer.from([0xc3, 0x28, 0xa0, 0xa1]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a truncated multibyte sequence", () => {
|
||||||
|
// First two bytes of a 3-byte character, cut short.
|
||||||
|
expect(isBinaryContent(Buffer.from([0xe6, 0x97]))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips text through UTF-8 without loss", () => {
|
||||||
|
const original = "acentuação, emoji 🚀, símbolos ±≠";
|
||||||
|
const buffer = Buffer.from(original);
|
||||||
|
|
||||||
|
expect(isBinaryContent(buffer)).toBe(false);
|
||||||
|
expect(buffer.toString("utf-8")).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves bytes that a UTF-8 decode would have replaced", () => {
|
||||||
|
const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x10, 0x4a]);
|
||||||
|
|
||||||
|
expect(isBinaryContent(bytes)).toBe(true);
|
||||||
|
// What the old text path would have produced, versus base64.
|
||||||
|
expect(Buffer.from(bytes.toString("utf-8"), "utf-8")).not.toEqual(bytes);
|
||||||
|
expect(Buffer.from(bytes.toString("base64"), "base64")).toEqual(bytes);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user