From 8e5f7e21bd121899f16981f7c5dd18408d147437 Mon Sep 17 00:00:00 2001 From: Ante-Florian Boras Date: Fri, 8 Aug 2025 17:21:30 +0200 Subject: [PATCH 1/7] Feat(filesystem): add `get_file_hash tool` (md5/sha1/sha256) Introduce a streaming file-hash tool using `crypto.createHash` and `fs.createReadStream`. Validates input via Zod and respects allowed-roots path checks. - Tool: `get_file_hash` - Args: { path: string, algorithm: "md5"|"sha1"|"sha256", encoding?: "hex"|"base64" } - Output: digest as hex (default) or base64 - Handler: added to CallToolRequest; included in ListTools Notes: - Rejects non-regular files (e.g., directories/devices) - Fails fast if algorithm is unavailable in this Node/OpenSSL build (e.g., FIPS) with a clear error message. --- src/filesystem/index.ts | 69 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 6723f43600..619d843916 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -13,7 +13,7 @@ import fs from "fs/promises"; import { createReadStream } from "fs"; import path from "path"; import os from 'os'; -import { randomBytes } from 'crypto'; +import { randomBytes, createHash, getHashes } from 'crypto'; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { diffLines, createTwoFilesPatch } from 'diff'; @@ -179,6 +179,12 @@ const GetFileInfoArgsSchema = z.object({ path: z.string(), }); +const GetFileHashArgsSchema = z.object({ + path: z.string(), + algorithm: z.enum(['md5', 'sha1', 'sha256']).default('sha256').describe('Hash algorithm to use'), + encoding: z.enum(['hex', 'base64']).default('hex').describe('Digest encoding') +}); + const ToolInputSchema = ToolSchema.shape.inputSchema; type ToolInput = z.infer; @@ -375,6 +381,45 @@ async function applyFileEdits( return formattedDiff; } +// Hashing utility +type HashAlgorithm = "md5" | "sha1" | "sha256"; +export async function getFileHash( + filePath: string, + algorithm: HashAlgorithm, + encoding: "hex" | "base64" = "hex" +): Promise { + const algo = algorithm.toLowerCase() as HashAlgorithm; + + // Fail early if Node/OpenSSL is not supported (FIPS/Builds) + const available = new Set(getHashes().map(h => h.toLowerCase())); + if (!available.has(algo)) { + throw new Error( + `Algorithm '${algo}' is not available in this Node/OpenSSL build (FIPS or policy may disable it).` + ); + } + + // Allow only regular files (throw a clear error if not) + const st = await fs.stat(filePath); + if (!st.isFile()) { + throw new Error(`Path is not a regular file: ${filePath}`); + } + + const hash = createHash(algo); + const stream = createReadStream(filePath, { highWaterMark: 1024 * 1024 }); // 1 MiB + + return await new Promise((resolve, reject) => { + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("error", (err) => reject(err)); + stream.on("end", () => { + try { + resolve(hash.digest(encoding)); + } catch (e) { + reject(e); + } + }); + }); +} + // Helper functions function formatSize(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB']; @@ -623,6 +668,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: [], }, }, + { + name: "get_file_hash", + description: + "Compute the cryptographic hash of a file (md5, sha1, or sha256). Returns the digest for the file’s contents." + + "Only works within allowed directories.", + inputSchema: zodToJsonSchema(GetFileHashArgsSchema) as ToolInput, + } ], }; }); @@ -944,6 +996,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case "get_file_hash": { + const parsed = GetFileHashArgsSchema.safeParse(args); + if (!parsed.success) { + throw new Error(`Invalid arguments for get_file_hash: ${parsed.error}`); + } + const validPath = await validatePath(parsed.data.path); + const hash = await getFileHash(validPath, parsed.data.algorithm, parsed.data.encoding); + return { + content: [{ + type: "text", + text: `algorithm: ${parsed.data.algorithm}\nencoding: ${parsed.data.encoding}\npath: ${parsed.data.path}\ndigest: ${hash}` + }], + }; + } + case "list_allowed_directories": { return { content: [{ From ea629ad7e925ceb59824f1d0e83b03f7a8ee60ab Mon Sep 17 00:00:00 2001 From: Ante-Florian Boras Date: Fri, 8 Aug 2025 17:46:54 +0200 Subject: [PATCH 2/7] Docs(README): include `get_file_hash` feature details --- src/filesystem/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index ac63f39a5f..6e0116c2df 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -9,6 +9,7 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - Move files/directories - Search files - Get file metadata +- Get file digest (md5, sha1, sha256) - Dynamic directory access control via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) ## Directory Access Control @@ -150,6 +151,21 @@ The server's directory access control follows this flow: - Type (file/directory) - Permissions +- **get_file_hash** + + - Compute the cryptographic hash of a regular file (md5, sha1, or sha256) + - Inputs: + - `path` (string): File to hash + - `algorithm` (`"md5" | "sha1" | "sha256"`, optional): Defaults to `"sha256"` + - `encoding` (`"hex" | "base64"`, optional): Digest encoding, defaults to `"hex"` + - Streams file contents for memory-efficient hashing + - Only operates within allowed directories + - Returns the digest as a string + - Notes: + - Fails if the path is not a regular file + - May error if the requested algorithm is unavailable in the current Node/OpenSSL build (e.g., FIPS mode) + - Digest encodings (`hex`, `base64`) are supported by Node’s `crypto` `Hash#digest`, and the filesystem server restricts operations to configured allowed directories. + - **list_allowed_directories** - List all directories the server is allowed to access - No input required From 0d824bff117ebafc89302021b7b2e6240f13a47d Mon Sep 17 00:00:00 2001 From: Ante-Florian Boras Date: Sat, 9 Aug 2025 12:25:56 +0200 Subject: [PATCH 3/7] refactor(`index.ts`): move `getFileHash` to `hash-file.ts` for isolated testing Extract `getFileHash` from `index.ts` into a standalone module to avoid pulling server bootstrap and top-level await into unit tests. This decouples hashing logic from transport setup and other side effects, allowing tests to import the function directly. No behavior change: the server now imports `getFileHash` from `hash-file.ts`. This prepares the codebase for comprehensive unit tests covering success and error paths. --- src/filesystem/file-hash.ts | 42 +++++++++++++++++++++++++++++++++++++ src/filesystem/index.ts | 5 +++-- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 src/filesystem/file-hash.ts diff --git a/src/filesystem/file-hash.ts b/src/filesystem/file-hash.ts new file mode 100644 index 0000000000..a9177187c0 --- /dev/null +++ b/src/filesystem/file-hash.ts @@ -0,0 +1,42 @@ +import { createHash, getHashes } from "crypto"; +import { createReadStream } from "fs"; +import fs from "fs/promises"; + +// Hashing utility +type HashAlgorithm = "md5" | "sha1" | "sha256"; +export async function getFileHash( + filePath: string, + algorithm: HashAlgorithm, + encoding: "hex" | "base64" = "hex" +): Promise { + const algo = algorithm.toLowerCase() as HashAlgorithm; + + // Fail early if Node/OpenSSL is not supported (FIPS/Builds) + const available = new Set(getHashes().map(h => h.toLowerCase())); + if (!available.has(algo)) { + throw new Error( + `Algorithm '${algo}' is not available in this Node/OpenSSL build (FIPS or policy may disable it).` + ); + } + + // Allow only regular files (throw a clear error if not) + const st = await fs.stat(filePath); + if (!st.isFile()) { + throw new Error(`Path is not a regular file: ${filePath}`); + } + + const hash = createHash(algo); + const stream = createReadStream(filePath, { highWaterMark: 1024 * 1024 }); // 1 MiB + + return await new Promise((resolve, reject) => { + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("error", (err) => reject(err)); + stream.on("end", () => { + try { + resolve(hash.digest(encoding)); + } catch (e) { + reject(e); + } + }); + }); +} \ No newline at end of file diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 619d843916..e25914ce06 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -20,6 +20,7 @@ import { diffLines, createTwoFilesPatch } from 'diff'; import { minimatch } from 'minimatch'; import { isPathWithinAllowedDirectories } from './path-validation.js'; import { getValidRootDirectories } from './roots-utils.js'; +import { getFileHash } from "./file-hash.js"; // Command line argument parsing const args = process.argv.slice(2); @@ -381,7 +382,7 @@ async function applyFileEdits( return formattedDiff; } -// Hashing utility +/* // Hashing utility type HashAlgorithm = "md5" | "sha1" | "sha256"; export async function getFileHash( filePath: string, @@ -418,7 +419,7 @@ export async function getFileHash( } }); }); -} +} */ // Helper functions function formatSize(bytes: number): string { From 110880317f4e0f957a31beccf6910131498c8462 Mon Sep 17 00:00:00 2001 From: Ante-Florian Boras Date: Sat, 9 Aug 2025 12:30:39 +0200 Subject: [PATCH 4/7] fix(file-hash): improve error handling for unsupported hash algorithms Add a policy gate to `getFileHash` that rejects any algorithm not in {md5, sha1, sha256}. Motivation: these are the widely used hashes in digital forensics; keeping the list small helps interoperability with DFIR tools and simplifies model/tool prompts. Also prepares unit tests to assert failure on unsupported algorithms (e.g., sha512, crc32, etc.). Runtime availability is still checked via crypto.getHashes to surface FIPS/build issues cleanly. Default remains sha256; md5/sha1 are retained for legacy sets. --- src/filesystem/file-hash.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/filesystem/file-hash.ts b/src/filesystem/file-hash.ts index a9177187c0..a5e15d23bc 100644 --- a/src/filesystem/file-hash.ts +++ b/src/filesystem/file-hash.ts @@ -10,7 +10,11 @@ export async function getFileHash( encoding: "hex" | "base64" = "hex" ): Promise { const algo = algorithm.toLowerCase() as HashAlgorithm; - + // Policy gate: allow only md5|sha1|sha256 (for DFIR interoperability) + if (!["md5","sha1","sha256"].includes(algo)) { + throw new Error(`Unsupported hash algorithm: ${algorithm}`); + } + // Fail early if Node/OpenSSL is not supported (FIPS/Builds) const available = new Set(getHashes().map(h => h.toLowerCase())); if (!available.has(algo)) { From 1b1480ae6380fced8e7ab91a98eb91fc363a8f9b Mon Sep 17 00:00:00 2001 From: Ante-Florian Boras Date: Sat, 9 Aug 2025 13:07:00 +0200 Subject: [PATCH 5/7] test(`file-hash`): add unit tests for `getFileHash` Add unit tests covering hashing of the text "ForensicShark" across md5/sha1/sha256 with expected digests. Validate rejection of non-regular paths (directory, symlink to directory, device like /dev/null when present). Verify hashing of a small binary snippet across all three algorithms. Assert that unsupported algorithms (e.g. sha512, crc32, whirlpool) throw per the policy gate. Exercise both encodings (hex and base64) for text and binary cases. Tests are platform-aware: skip the device case on Windows and create a junction for the directory symlink on Windows. --- src/filesystem/__tests__/file-hash.test.ts | 135 +++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/filesystem/__tests__/file-hash.test.ts diff --git a/src/filesystem/__tests__/file-hash.test.ts b/src/filesystem/__tests__/file-hash.test.ts new file mode 100644 index 0000000000..8580b8f7dc --- /dev/null +++ b/src/filesystem/__tests__/file-hash.test.ts @@ -0,0 +1,135 @@ +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { getFileHash } from "../file-hash.js"; + +describe("get_file_hash (complete coverage)", () => { + let tmpDir: string; + let textFile: string; + let binFile: string; + let dirPath: string; + let symlinkToDir: string; + + // Test data + const TEXT = "ForensicShark"; + // Expected digests for "ForensicShark" (without newline) + const TEXT_DIGESTS = { + md5_hex: "1422ac7778fd50963651bc74686158b7", + sha1_hex: "a74904ee14c16d949256e96110596bdffc48f481", + sha256_hex: "53746f49c75306a3066eb456dba05b99aab88f562d2c020582c9226d9c969987", + md5_b64: "FCKsd3j9UJY2Ubx0aGFYtw==", + sha1_b64: "p0kE7hTBbZSSVulhEFlr3/xI9IE=", + sha256_b64: "U3RvScdTBqMGbrRW26Bbmaq4j1YtLAIFgskibZyWmYc=", + } as const; + + // Small binary snippet: 00 FF 10 20 42 7F + const BIN_SNIPPET = Buffer.from([0x00, 0xff, 0x10, 0x20, 0x42, 0x7f]); + const BIN_DIGESTS = { + md5_hex: "3bd2f5d961a05d8cb7edd3953adc069c", + sha1_hex: "28541834deba1f200e2fbde455bddb2e258afe36", + sha256_hex: "6048e89b6ff39be935d44c069a21f22ae7401177ee4c7d3156a4e3b48102d53f", + md5_b64: "O9L12WGgXYy37dOVOtwGnA==", + sha1_b64: "KFQYNN66HyAOL73kVb3bLiWK/jY=", + sha256_b64: "YEjom2/zm+k11EwGmiHyKudAEXfuTH0xVqTjtIEC1T8=", + } as const; + + beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "get-file-hash-")); + textFile = path.join(tmpDir, "text.txt"); + binFile = path.join(tmpDir, "bin.dat"); + dirPath = path.join(tmpDir, "a-directory"); + symlinkToDir = path.join(tmpDir, "dir-link"); + + await fs.writeFile(textFile, TEXT, "utf-8"); + await fs.writeFile(binFile, BIN_SNIPPET); + await fs.mkdir(dirPath); + + // Symlink to directory (on Windows: "junction") + if (process.platform === "win32") { + await fs.symlink(dirPath, symlinkToDir, "junction"); + } else { + await fs.symlink(dirPath, symlinkToDir); + } + }); + + afterAll(async () => { + // Cleanup + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + // + // 1) Text 'ForensicShark' → md5/sha1/sha256 (hex) + // + test("hash of text 'ForensicShark' (md5/sha1/sha256, hex)", async () => { + await expect(getFileHash(textFile, "md5", "hex")).resolves.toBe(TEXT_DIGESTS.md5_hex); + await expect(getFileHash(textFile, "sha1", "hex")).resolves.toBe(TEXT_DIGESTS.sha1_hex); + await expect(getFileHash(textFile, "sha256", "hex")).resolves.toBe(TEXT_DIGESTS.sha256_hex); + }); + + // + // 2) Not a file: directory, symlink to directory, /dev/null (if present) + // + test("rejects directory as not a regular file", async () => { + await expect(getFileHash(dirPath, "sha256", "hex")).rejects.toThrow(/not a regular file/i); + }); + + test("rejects symlink to directory as not a regular file", async () => { + await expect(getFileHash(symlinkToDir, "sha256", "hex")).rejects.toThrow(/not a regular file/i); + }); + + test("rejects device file like /dev/null when present", async () => { + if (process.platform === "win32") { + // No /dev/null → skip test + return; + } + try { + const devNull = "/dev/null"; + const st = await fs.lstat(devNull); + // If present & not a regular file → expected error + if (!st.isFile()) { + await expect(getFileHash(devNull, "sha256", "hex")).rejects.toThrow(/not a regular file|EISDIR|EPERM|EINVAL/i); + } + } catch { + // /dev/null does not exist → skip + return; + } + }); + + // + // 3) Binary snippet correct (all three algorithms, hex) + // + test("hash of small binary snippet (md5/sha1/sha256, hex)", async () => { + await expect(getFileHash(binFile, "md5", "hex")).resolves.toBe(BIN_DIGESTS.md5_hex); + await expect(getFileHash(binFile, "sha1", "hex")).resolves.toBe(BIN_DIGESTS.sha1_hex); + await expect(getFileHash(binFile, "sha256", "hex")).resolves.toBe(BIN_DIGESTS.sha256_hex); + }); + + // + // 4) Unknown algorithms → error (at least three) + // We intentionally use common but NOT allowed names (sha512) + // plus fantasy/legacy names, so the test remains stable. + // + test("rejects unsupported algorithms", async () => { + const badAlgos = ["sha512", "crc32", "whirlpool", "shark512", "legacy-md5"]; + for (const algo of badAlgos) { + // cast to any to bypass TS union, we test runtime errors + await expect(getFileHash(textFile, algo as any, "hex")).rejects.toThrow(/algorithm|unsupported|not available/i); + } + }); + + // + // 5) Encodings hex & base64 correct + // + test("encodings: hex and base64 (text case)", async () => { + // hex wurde oben schon geprüft; hier nochmals base64 explizit + await expect(getFileHash(textFile, "md5", "base64")).resolves.toBe(TEXT_DIGESTS.md5_b64); + await expect(getFileHash(textFile, "sha1", "base64")).resolves.toBe(TEXT_DIGESTS.sha1_b64); + await expect(getFileHash(textFile, "sha256", "base64")).resolves.toBe(TEXT_DIGESTS.sha256_b64); + }); + + test("encodings: hex and base64 (binary case)", async () => { + await expect(getFileHash(binFile, "md5", "base64")).resolves.toBe(BIN_DIGESTS.md5_b64); + await expect(getFileHash(binFile, "sha1", "base64")).resolves.toBe(BIN_DIGESTS.sha1_b64); + await expect(getFileHash(binFile, "sha256", "base64")).resolves.toBe(BIN_DIGESTS.sha256_b64); + }); +}); From 14b402be7e8edeadfdeb3386e94fab7d7016a605 Mon Sep 17 00:00:00 2001 From: Ante-Florian Boras Date: Sat, 9 Aug 2025 14:44:46 +0200 Subject: [PATCH 6/7] refactor(`index.ts`): remove unused getFileHash function and related comments --- src/filesystem/index.ts | 39 --------------------------------------- 1 file changed, 39 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index e25914ce06..5a1b50876e 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -382,45 +382,6 @@ async function applyFileEdits( return formattedDiff; } -/* // Hashing utility -type HashAlgorithm = "md5" | "sha1" | "sha256"; -export async function getFileHash( - filePath: string, - algorithm: HashAlgorithm, - encoding: "hex" | "base64" = "hex" -): Promise { - const algo = algorithm.toLowerCase() as HashAlgorithm; - - // Fail early if Node/OpenSSL is not supported (FIPS/Builds) - const available = new Set(getHashes().map(h => h.toLowerCase())); - if (!available.has(algo)) { - throw new Error( - `Algorithm '${algo}' is not available in this Node/OpenSSL build (FIPS or policy may disable it).` - ); - } - - // Allow only regular files (throw a clear error if not) - const st = await fs.stat(filePath); - if (!st.isFile()) { - throw new Error(`Path is not a regular file: ${filePath}`); - } - - const hash = createHash(algo); - const stream = createReadStream(filePath, { highWaterMark: 1024 * 1024 }); // 1 MiB - - return await new Promise((resolve, reject) => { - stream.on("data", (chunk) => hash.update(chunk)); - stream.on("error", (err) => reject(err)); - stream.on("end", () => { - try { - resolve(hash.digest(encoding)); - } catch (e) { - reject(e); - } - }); - }); -} */ - // Helper functions function formatSize(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB']; From dd3a0966f5c725088eb1f585a52ece21d0e84ae8 Mon Sep 17 00:00:00 2001 From: Ante-Florian Boras Date: Sat, 9 Aug 2025 16:15:04 +0200 Subject: [PATCH 7/7] fix(`get_file_hash`): clarify tool description for optional encoding Refine the `get_file_hash` tool description to be concise and Qwen-friendly with properly escaped quotes. Document encoding as optional with default "hex"; require an absolute path and state the input must be a regular file under allowed directories (not directories/devices). Instruct models to return only the digest string. This improves tool-calling reliability and matches the Zod schema and server defaults. --- src/filesystem/index.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 5a1b50876e..e07d292c74 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -631,12 +631,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, }, { - name: "get_file_hash", - description: - "Compute the cryptographic hash of a file (md5, sha1, or sha256). Returns the digest for the file’s contents." + - "Only works within allowed directories.", - inputSchema: zodToJsonSchema(GetFileHashArgsSchema) as ToolInput, - } + name: "get_file_hash", + description: + "Compute the cryptographic hash of a file for integrity verification. " + + "Use only for regular files within allowed directories (not directories/devices). " + + "Inputs: { path: absolute path, algorithm: \"md5\"|\"sha1\"|\"sha256\", " + + "encoding: \"hex\"|\"base64\" (optional, default \"hex\") }. " + + "Return only the digest string. Call when verifying file integrity or comparing files.", + inputSchema: zodToJsonSchema(GetFileHashArgsSchema) as ToolInput, + } ], }; }); @@ -964,11 +967,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error(`Invalid arguments for get_file_hash: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); - const hash = await getFileHash(validPath, parsed.data.algorithm, parsed.data.encoding); + const encoding = parsed.data.encoding ?? "hex"; + const hash = await getFileHash(validPath, parsed.data.algorithm, encoding); return { content: [{ type: "text", - text: `algorithm: ${parsed.data.algorithm}\nencoding: ${parsed.data.encoding}\npath: ${parsed.data.path}\ndigest: ${hash}` + text: `algorithm: ${parsed.data.algorithm}\nencoding: ${encoding}\npath: ${parsed.data.path}\ndigest: ${hash}` }], }; }