fsguard-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fsguard_mcp-0.1.0/.gitignore +8 -0
- fsguard_mcp-0.1.0/PKG-INFO +86 -0
- fsguard_mcp-0.1.0/README.md +72 -0
- fsguard_mcp-0.1.0/pyproject.toml +31 -0
- fsguard_mcp-0.1.0/src/fsguard_mcp/__init__.py +4 -0
- fsguard_mcp-0.1.0/src/fsguard_mcp/confined_path.py +169 -0
- fsguard_mcp-0.1.0/src/fsguard_mcp/fs.py +84 -0
- fsguard_mcp-0.1.0/src/fsguard_mcp/git_ops.py +156 -0
- fsguard_mcp-0.1.0/src/fsguard_mcp/server.py +157 -0
- fsguard_mcp-0.1.0/tests/test_confined_path.py +308 -0
- fsguard_mcp-0.1.0/tests/test_fs.py +189 -0
- fsguard_mcp-0.1.0/tests/test_git_ops.py +237 -0
- fsguard_mcp-0.1.0/tests/test_server.py +85 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fsguard-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Filesystem + git MCP server confined by symlink-resolved path containment, not string prefix matching.
|
|
5
|
+
Author: Berkant Acun
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: filesystem,git,mcp,model-context-protocol,sandbox,security
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: dulwich>=0.22.0
|
|
10
|
+
Requires-Dist: mcp>=1.2.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# fsguard-mcp
|
|
16
|
+
|
|
17
|
+
A filesystem + git MCP server that confines every operation to an allowed
|
|
18
|
+
directory tree using **symlink-resolved path containment**, not string
|
|
19
|
+
prefix matching.
|
|
20
|
+
|
|
21
|
+
## Why this exists
|
|
22
|
+
|
|
23
|
+
Anthropic's own official filesystem and git MCP servers (`@modelcontextprotocol/server-filesystem`, part of `modelcontextprotocol/servers`, 89.7k★) have carried **five separate path-confinement CVEs across two servers in ten months**, and the pattern is still active:
|
|
24
|
+
|
|
25
|
+
- **CVE-2025-53109 / CVE-2025-53110** (filesystem, CVSS 8.4/7.3) — the "allowed directory" check used naive `startsWith()` prefix matching, defeated by symlinks and by sibling directories that merely *share a string prefix* (e.g. an allowed `/home/user-safe` also matches `/home/user-safe-evil`), giving filesystem-wide read/write and a documented RCE path.
|
|
26
|
+
- **CVE-2025-68143 / CVE-2025-68144 / CVE-2025-68145** (git) — `git_init` accepted arbitrary unvalidated paths, `git_diff`/`git_checkout` passed user-controlled arguments straight to the `git` CLI (argument injection), and `--repository`-confined mode didn't actually verify `repo_path` stayed inside the confined directory.
|
|
27
|
+
- **CVE-2026-27735** (git, disclosed ~2 months before this project started) — `git_add`, implemented via GitPython's `repo.index.add()`, doesn't enforce working-tree boundaries for `../`-style paths, allowing staging and exfiltrating files outside the repo.
|
|
28
|
+
- A documented RCE chain: `git_init` in a writable directory → a malicious `.git/config` with a "clean" filter → a `.gitattributes` that applies it → `git_add` triggers the filter → arbitrary shell command runs.
|
|
29
|
+
|
|
30
|
+
Every one of these was patched with *another string/prefix check bolted onto that one function*. Nobody moved the boundary enforcement to a place a new tool can't simply forget to include — which is exactly how the fourth CVE landed four months after the first three were "fixed."
|
|
31
|
+
|
|
32
|
+
## How fsguard-mcp is different
|
|
33
|
+
|
|
34
|
+
1. **One safety primitive, used everywhere.** Every tool — filesystem or git — resolves its target path through the same `ConfinedRoot` (see `confined_path.py`) before doing anything else. There's no per-tool path check to forget.
|
|
35
|
+
2. **Symlink-resolved, component-based containment — not string matching.** A path is only inside the root if its *fully resolved* real path (every symlink followed) is a real ancestor-relative subpath of the root's *own* resolved real path, checked with `Path.is_relative_to()` on resolved paths — never `startswith()` on a string. This alone closes CVE-2025-53109/53110's exact failure mode: `/allowed-evil` cannot pass a containment check against a resolved root of `/allowed`, because path-component comparison isn't string-prefix comparison.
|
|
36
|
+
3. **No shelling out to `git` for content, ever.** Git operations run through `dulwich` — a pure-Python git implementation with no subprocess and no argv built from user input for anything content-related, and (critically) **no clean/smudge filter execution**, which is what the documented RCE chain depends on. There is no argument-injection surface here because there's no argument list being handed to an external process for reading/writing file content. (dulwich *does* still run `pre-commit`/`commit-msg`/`post-commit` hooks via `subprocess.call()` if they exist — real process execution, unrelated to content filtering. `git_commit` always passes `no_verify=True` to skip them categorically, rather than relying on them happening not to be runnable.)
|
|
37
|
+
4. **Write operations validate the parent directory too**, not just an existing target — closing the class of bug where a target doesn't exist yet (so "does this path resolve inside the root" was checked against a path that doesn't exist, and therefore couldn't be symlink-resolved) but its parent directory is itself a symlink pointing outside. Non-existent path segments are lexically normalized (`.`/`..` collapsed as pure path algebra) *before* any of this, independent of what happens to exist on disk — an earlier version of this project checked containment before normalizing, which happened to pass all its tests on Windows (whose path APIs normalize `..` for you) while being bypassable on Linux/macOS. It's fixed now, and there are tests for the exact case, but it's the reason this project treats "the test suite is green on my machine" with real suspicion.
|
|
38
|
+
5. **`.git/config` can't redirect operations outside the root.** dulwich honors a repo's own `core.worktree` config entry, and every git operation re-opens a `Repo` from a path string internally — so a caller could write a `.git/config` with `core.worktree` pointing anywhere, and every subsequent git tool would silently operate outside the confined root, invisible to the per-path check (which only ever sees the confined repo directory, never wherever dulwich actually redirected itself to). This was found in this project's own second-round security review — a real read/exfiltration primitive using nothing but this server's own exposed tools, more severe than any CVE it was built to fix. Every git tool now refuses to open a repo whose config sets `core.worktree` at all, and independently re-verifies that the `Repo` object it actually opened reports its working path as the exact directory that was validated.
|
|
39
|
+
6. **UNC paths and cross-drive paths are rejected before touching the network or disk at all.** Resolving a `\\host\share\...` path makes Windows actually attempt an SMB connection — and Windows will try to authenticate that connection as the server process, which is the "forced NTLM auth via UNC path" credential-theft technique, on top of blocking the server for a full connection timeout against an unreachable host. A candidate anchored on a different drive or host than the confined root is now rejected by a cheap string comparison, before any filesystem or network call. NTFS Alternate Data Streams (`file.txt:hidden`) are also rejected outright — they're invisible to directory listings but fully readable/writable through the same path string, and can forge the absence of Windows' download-warning "Mark of the Web."
|
|
40
|
+
|
|
41
|
+
## Tools
|
|
42
|
+
|
|
43
|
+
| Tool | Does |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `fs_read(path)` | Read a text file |
|
|
46
|
+
| `fs_write(path, content)` | Create or overwrite a text file |
|
|
47
|
+
| `fs_list(path=".")` | List a directory's entries |
|
|
48
|
+
| `fs_search(pattern, path=".")` | Find files matching a glob pattern, recursively |
|
|
49
|
+
| `fs_move(source, destination)` | Move/rename a file |
|
|
50
|
+
| `git_init_repo(repo_path)` | Initialize a git repository |
|
|
51
|
+
| `git_repo_status(repo_path=".")` | Staged/unstaged/untracked files |
|
|
52
|
+
| `git_stage(repo_path, paths)` | Stage files |
|
|
53
|
+
| `git_commit_repo(repo_path, message, author)` | Commit staged changes |
|
|
54
|
+
| `git_diff_repo(repo_path=".", staged=False)` | Show a diff |
|
|
55
|
+
| `git_log_repo(repo_path=".", max_entries=10)` | Show commit history |
|
|
56
|
+
|
|
57
|
+
## Setup
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install fsguard-mcp
|
|
61
|
+
export FSGUARD_ROOT="/path/to/the/one/directory/tree/this/server/may/touch"
|
|
62
|
+
fsguard-mcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`FSGUARD_ROOT` is required — there is no default, and the server refuses to guess one. Point your MCP client at the `fsguard-mcp` command with `FSGUARD_ROOT` set in its env config.
|
|
66
|
+
|
|
67
|
+
## Testing
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install -e ".[dev]"
|
|
71
|
+
pytest tests/ -v
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
All 68 tests are self-contained (real temp directories, real symlinks, real git repos) — no external services needed.
|
|
75
|
+
|
|
76
|
+
## Known limitation
|
|
77
|
+
|
|
78
|
+
Containment is checked, then a filesystem operation runs — there is an inherent TOCTOU (time-of-check-to-time-of-use) gap between the two. A concurrent process with write access to the confined root's own tree could in principle swap a symlink in that window (verified with a working proof-of-concept during review). Closing this fully needs an OS-level primitive (e.g. Linux `openat2(RESOLVE_BENEATH)`, a real mount namespace) rather than anything achievable in portable Python; this project's guarantee is "correct containment logic, checked immediately before use," not "immune to a concurrent attacker who can already write inside the root."
|
|
79
|
+
|
|
80
|
+
## Status
|
|
81
|
+
|
|
82
|
+
68 passing tests (unit-level, with real symlinks and real git repos created on disk — not just string-logic assertions). Went through two rounds of adversarial security review before its first commit; both found real, working bypasses (a `..`-traversal escape through not-yet-existing paths on POSIX, and the `core.worktree` redirection above, among smaller findings) that are now fixed and covered by tests written directly against the reported exploit. Not yet published to PyPI.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# fsguard-mcp
|
|
2
|
+
|
|
3
|
+
A filesystem + git MCP server that confines every operation to an allowed
|
|
4
|
+
directory tree using **symlink-resolved path containment**, not string
|
|
5
|
+
prefix matching.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
Anthropic's own official filesystem and git MCP servers (`@modelcontextprotocol/server-filesystem`, part of `modelcontextprotocol/servers`, 89.7k★) have carried **five separate path-confinement CVEs across two servers in ten months**, and the pattern is still active:
|
|
10
|
+
|
|
11
|
+
- **CVE-2025-53109 / CVE-2025-53110** (filesystem, CVSS 8.4/7.3) — the "allowed directory" check used naive `startsWith()` prefix matching, defeated by symlinks and by sibling directories that merely *share a string prefix* (e.g. an allowed `/home/user-safe` also matches `/home/user-safe-evil`), giving filesystem-wide read/write and a documented RCE path.
|
|
12
|
+
- **CVE-2025-68143 / CVE-2025-68144 / CVE-2025-68145** (git) — `git_init` accepted arbitrary unvalidated paths, `git_diff`/`git_checkout` passed user-controlled arguments straight to the `git` CLI (argument injection), and `--repository`-confined mode didn't actually verify `repo_path` stayed inside the confined directory.
|
|
13
|
+
- **CVE-2026-27735** (git, disclosed ~2 months before this project started) — `git_add`, implemented via GitPython's `repo.index.add()`, doesn't enforce working-tree boundaries for `../`-style paths, allowing staging and exfiltrating files outside the repo.
|
|
14
|
+
- A documented RCE chain: `git_init` in a writable directory → a malicious `.git/config` with a "clean" filter → a `.gitattributes` that applies it → `git_add` triggers the filter → arbitrary shell command runs.
|
|
15
|
+
|
|
16
|
+
Every one of these was patched with *another string/prefix check bolted onto that one function*. Nobody moved the boundary enforcement to a place a new tool can't simply forget to include — which is exactly how the fourth CVE landed four months after the first three were "fixed."
|
|
17
|
+
|
|
18
|
+
## How fsguard-mcp is different
|
|
19
|
+
|
|
20
|
+
1. **One safety primitive, used everywhere.** Every tool — filesystem or git — resolves its target path through the same `ConfinedRoot` (see `confined_path.py`) before doing anything else. There's no per-tool path check to forget.
|
|
21
|
+
2. **Symlink-resolved, component-based containment — not string matching.** A path is only inside the root if its *fully resolved* real path (every symlink followed) is a real ancestor-relative subpath of the root's *own* resolved real path, checked with `Path.is_relative_to()` on resolved paths — never `startswith()` on a string. This alone closes CVE-2025-53109/53110's exact failure mode: `/allowed-evil` cannot pass a containment check against a resolved root of `/allowed`, because path-component comparison isn't string-prefix comparison.
|
|
22
|
+
3. **No shelling out to `git` for content, ever.** Git operations run through `dulwich` — a pure-Python git implementation with no subprocess and no argv built from user input for anything content-related, and (critically) **no clean/smudge filter execution**, which is what the documented RCE chain depends on. There is no argument-injection surface here because there's no argument list being handed to an external process for reading/writing file content. (dulwich *does* still run `pre-commit`/`commit-msg`/`post-commit` hooks via `subprocess.call()` if they exist — real process execution, unrelated to content filtering. `git_commit` always passes `no_verify=True` to skip them categorically, rather than relying on them happening not to be runnable.)
|
|
23
|
+
4. **Write operations validate the parent directory too**, not just an existing target — closing the class of bug where a target doesn't exist yet (so "does this path resolve inside the root" was checked against a path that doesn't exist, and therefore couldn't be symlink-resolved) but its parent directory is itself a symlink pointing outside. Non-existent path segments are lexically normalized (`.`/`..` collapsed as pure path algebra) *before* any of this, independent of what happens to exist on disk — an earlier version of this project checked containment before normalizing, which happened to pass all its tests on Windows (whose path APIs normalize `..` for you) while being bypassable on Linux/macOS. It's fixed now, and there are tests for the exact case, but it's the reason this project treats "the test suite is green on my machine" with real suspicion.
|
|
24
|
+
5. **`.git/config` can't redirect operations outside the root.** dulwich honors a repo's own `core.worktree` config entry, and every git operation re-opens a `Repo` from a path string internally — so a caller could write a `.git/config` with `core.worktree` pointing anywhere, and every subsequent git tool would silently operate outside the confined root, invisible to the per-path check (which only ever sees the confined repo directory, never wherever dulwich actually redirected itself to). This was found in this project's own second-round security review — a real read/exfiltration primitive using nothing but this server's own exposed tools, more severe than any CVE it was built to fix. Every git tool now refuses to open a repo whose config sets `core.worktree` at all, and independently re-verifies that the `Repo` object it actually opened reports its working path as the exact directory that was validated.
|
|
25
|
+
6. **UNC paths and cross-drive paths are rejected before touching the network or disk at all.** Resolving a `\\host\share\...` path makes Windows actually attempt an SMB connection — and Windows will try to authenticate that connection as the server process, which is the "forced NTLM auth via UNC path" credential-theft technique, on top of blocking the server for a full connection timeout against an unreachable host. A candidate anchored on a different drive or host than the confined root is now rejected by a cheap string comparison, before any filesystem or network call. NTFS Alternate Data Streams (`file.txt:hidden`) are also rejected outright — they're invisible to directory listings but fully readable/writable through the same path string, and can forge the absence of Windows' download-warning "Mark of the Web."
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
| Tool | Does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `fs_read(path)` | Read a text file |
|
|
32
|
+
| `fs_write(path, content)` | Create or overwrite a text file |
|
|
33
|
+
| `fs_list(path=".")` | List a directory's entries |
|
|
34
|
+
| `fs_search(pattern, path=".")` | Find files matching a glob pattern, recursively |
|
|
35
|
+
| `fs_move(source, destination)` | Move/rename a file |
|
|
36
|
+
| `git_init_repo(repo_path)` | Initialize a git repository |
|
|
37
|
+
| `git_repo_status(repo_path=".")` | Staged/unstaged/untracked files |
|
|
38
|
+
| `git_stage(repo_path, paths)` | Stage files |
|
|
39
|
+
| `git_commit_repo(repo_path, message, author)` | Commit staged changes |
|
|
40
|
+
| `git_diff_repo(repo_path=".", staged=False)` | Show a diff |
|
|
41
|
+
| `git_log_repo(repo_path=".", max_entries=10)` | Show commit history |
|
|
42
|
+
|
|
43
|
+
## Setup
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install fsguard-mcp
|
|
47
|
+
export FSGUARD_ROOT="/path/to/the/one/directory/tree/this/server/may/touch"
|
|
48
|
+
fsguard-mcp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`FSGUARD_ROOT` is required — there is no default, and the server refuses to guess one. Point your MCP client at the `fsguard-mcp` command with `FSGUARD_ROOT` set in its env config.
|
|
52
|
+
|
|
53
|
+
## Testing
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install -e ".[dev]"
|
|
57
|
+
pytest tests/ -v
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
All 68 tests are self-contained (real temp directories, real symlinks, real git repos) — no external services needed.
|
|
61
|
+
|
|
62
|
+
## Known limitation
|
|
63
|
+
|
|
64
|
+
Containment is checked, then a filesystem operation runs — there is an inherent TOCTOU (time-of-check-to-time-of-use) gap between the two. A concurrent process with write access to the confined root's own tree could in principle swap a symlink in that window (verified with a working proof-of-concept during review). Closing this fully needs an OS-level primitive (e.g. Linux `openat2(RESOLVE_BENEATH)`, a real mount namespace) rather than anything achievable in portable Python; this project's guarantee is "correct containment logic, checked immediately before use," not "immune to a concurrent attacker who can already write inside the root."
|
|
65
|
+
|
|
66
|
+
## Status
|
|
67
|
+
|
|
68
|
+
68 passing tests (unit-level, with real symlinks and real git repos created on disk — not just string-logic assertions). Went through two rounds of adversarial security review before its first commit; both found real, working bypasses (a `..`-traversal escape through not-yet-existing paths on POSIX, and the `core.worktree` redirection above, among smaller findings) that are now fixed and covered by tests written directly against the reported exploit. Not yet published to PyPI.
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fsguard-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Filesystem + git MCP server confined by symlink-resolved path containment, not string prefix matching."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Berkant Acun" }]
|
|
9
|
+
keywords = ["mcp", "model-context-protocol", "filesystem", "git", "security", "sandbox"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp>=1.2.0",
|
|
12
|
+
"dulwich>=0.22.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
fsguard-mcp = "fsguard_mcp.server:main"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/fsguard_mcp"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The core safety primitive: symlink-resolved, component-based path
|
|
3
|
+
containment. Every filesystem and git tool goes through this before
|
|
4
|
+
touching disk — see README.md for why.
|
|
5
|
+
|
|
6
|
+
The failure mode this fixes: CVE-2025-53109/53110 used `startsWith()`
|
|
7
|
+
string-prefix matching on a path to decide if it was "inside" the
|
|
8
|
+
allowed directory. That's defeated two ways: (1) a symlink inside the
|
|
9
|
+
allowed root whose target is outside it — the string check never
|
|
10
|
+
resolves the symlink, so it never sees the real destination; (2) a
|
|
11
|
+
sibling directory that merely shares a string prefix with the allowed
|
|
12
|
+
root (an allowed `/safe` also string-matches `/safe-evil`), which has
|
|
13
|
+
nothing to do with actual containment.
|
|
14
|
+
|
|
15
|
+
The fix here is to always resolve the candidate path through the real
|
|
16
|
+
filesystem (following every symlink) and then compare using path
|
|
17
|
+
*component* containment (`Path.is_relative_to()`), never string
|
|
18
|
+
comparison. Two paths that share characters but differ in even one
|
|
19
|
+
path component are never "contained" by this check, symlink or not.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PathEscapeError(ValueError):
|
|
29
|
+
"""Raised when a path would resolve outside the confined root."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfinedRoot:
|
|
33
|
+
"""Confines all path resolution to a single directory tree."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, root: str | Path) -> None:
|
|
36
|
+
resolved = Path(root).resolve(strict=True)
|
|
37
|
+
if not resolved.is_dir():
|
|
38
|
+
raise NotADirectoryError(f"Confined root is not a directory: {resolved}")
|
|
39
|
+
if resolved.parent == resolved:
|
|
40
|
+
# A drive root (C:\) or the POSIX root (/) itself — "confined"
|
|
41
|
+
# to one of these is not confinement at all. Almost always a
|
|
42
|
+
# misconfigured or forgotten FSGUARD_ROOT, not an intentional
|
|
43
|
+
# choice, and worth failing loudly rather than silently
|
|
44
|
+
# granting effectively unrestricted access.
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Refusing to use a filesystem root as the confined root: {resolved}"
|
|
47
|
+
)
|
|
48
|
+
self.root = resolved
|
|
49
|
+
|
|
50
|
+
def resolve(self, candidate: str | Path, *, must_exist: bool = True) -> Path:
|
|
51
|
+
"""Resolve `candidate` (relative to the root, or absolute) and
|
|
52
|
+
verify it's contained in the root after following every symlink.
|
|
53
|
+
|
|
54
|
+
must_exist=True (the default, for reads and anything that must
|
|
55
|
+
already exist) requires the full path to exist and resolves it
|
|
56
|
+
directly.
|
|
57
|
+
|
|
58
|
+
must_exist=False (for write targets that may not exist yet)
|
|
59
|
+
walks up to the nearest existing ancestor, resolves *that*
|
|
60
|
+
through any symlinks, and reattaches the non-existent tail —
|
|
61
|
+
so a symlinked parent directory pointing outside the root is
|
|
62
|
+
still caught, even though the final target itself is new.
|
|
63
|
+
|
|
64
|
+
Raises PathEscapeError if the resolved path is not the root
|
|
65
|
+
itself or a real descendant of it.
|
|
66
|
+
"""
|
|
67
|
+
candidate_path = Path(candidate)
|
|
68
|
+
self._reject_alternate_data_streams(candidate_path)
|
|
69
|
+
|
|
70
|
+
combined = candidate_path if candidate_path.is_absolute() else (self.root / candidate_path)
|
|
71
|
+
|
|
72
|
+
# Cheap, filesystem-free rejection of anything anchored on a
|
|
73
|
+
# different drive or UNC host than the confined root — checked
|
|
74
|
+
# *before* anything below ever calls .exists()/.resolve(), which
|
|
75
|
+
# would otherwise make the OS actually attempt an SMB connection
|
|
76
|
+
# to an attacker-supplied \\host\share path. That's not just an
|
|
77
|
+
# escape risk: Windows will try to authenticate that connection
|
|
78
|
+
# as the server process, which is the textbook "forced NTLM auth
|
|
79
|
+
# via UNC path" credential-theft technique — and even a merely
|
|
80
|
+
# unreachable host blocks this synchronous server for the length
|
|
81
|
+
# of a full SMB connection timeout. A relative candidate always
|
|
82
|
+
# inherits the root's own drive here, so this never affects
|
|
83
|
+
# ordinary use.
|
|
84
|
+
if combined.drive.lower() != self.root.drive.lower():
|
|
85
|
+
raise PathEscapeError(
|
|
86
|
+
f"Path is anchored on a different drive or host than the confined root: {candidate!r}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# The containment check always uses the missing-leaf-safe walk,
|
|
90
|
+
# whether or not the caller requires the path to exist — this is
|
|
91
|
+
# deliberate: it means "is this path allowed" and "does this
|
|
92
|
+
# path exist" are two separate questions, checked in that order,
|
|
93
|
+
# so a plain missing file surfaces as FileNotFoundError instead
|
|
94
|
+
# of being misreported as a security violation.
|
|
95
|
+
resolved = self._resolve_allowing_missing_leaf(combined)
|
|
96
|
+
|
|
97
|
+
if not self._is_contained(resolved):
|
|
98
|
+
raise PathEscapeError(
|
|
99
|
+
f"Path resolves outside the confined root {self.root}: {candidate!r} -> {resolved}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if must_exist and not resolved.exists():
|
|
103
|
+
raise FileNotFoundError(f"Path does not exist: {candidate}")
|
|
104
|
+
|
|
105
|
+
return resolved
|
|
106
|
+
|
|
107
|
+
def _reject_alternate_data_streams(self, candidate_path: Path) -> None:
|
|
108
|
+
# NTFS Alternate Data Streams use "name:stream" syntax. A stream
|
|
109
|
+
# is invisible to iterdir()/rglob() (so fs_list/fs_search would
|
|
110
|
+
# never show it) but fully readable and writable through the
|
|
111
|
+
# same path string — enough to hide content from directory
|
|
112
|
+
# listings, or to write a ":Zone.Identifier" stream that forges
|
|
113
|
+
# the absence of Windows' "Mark of the Web" on a file. Reject any
|
|
114
|
+
# ':' outside the drive-letter position in any path component.
|
|
115
|
+
for i, part in enumerate(candidate_path.parts):
|
|
116
|
+
if i == 0 and candidate_path.drive:
|
|
117
|
+
continue # e.g. "C:\\" itself, not a stream
|
|
118
|
+
if ":" in part:
|
|
119
|
+
raise PathEscapeError(
|
|
120
|
+
f"Path component contains ':' (possible alternate data stream): {part!r}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _resolve_allowing_missing_leaf(self, combined: Path) -> Path:
|
|
124
|
+
# Step 1 — lexical normalization, BEFORE touching the
|
|
125
|
+
# filesystem at all. This is not optional: walking up to the
|
|
126
|
+
# nearest *existing* ancestor by repeatedly taking .parent
|
|
127
|
+
# treats a literal ".." component as just another path segment
|
|
128
|
+
# to pop, not as "go up a level" — so a not-yet-existing tail
|
|
129
|
+
# like "does/not/exist/../../../../outside.txt" would keep its
|
|
130
|
+
# ".." tokens completely uninterpreted, and the later
|
|
131
|
+
# containment check (a lexical comparison of path components)
|
|
132
|
+
# would see them as harmless extra segments still "under" the
|
|
133
|
+
# root, even though the real filesystem — once mkdir/move
|
|
134
|
+
# actually creates each missing ancestor — resolves them right
|
|
135
|
+
# out of the confined root. os.path.normpath() collapses "."
|
|
136
|
+
# and ".." purely as path algebra, with no filesystem access,
|
|
137
|
+
# so this can never depend on what happens to exist on disk.
|
|
138
|
+
normalized = Path(os.path.normpath(str(combined)))
|
|
139
|
+
|
|
140
|
+
# Step 2 — walk up the now ".."-free path to the nearest
|
|
141
|
+
# existing ancestor, resolve *that* through any symlinks, and
|
|
142
|
+
# reattach the (already-normalized, so safe to rejoin literally)
|
|
143
|
+
# missing tail. This is what catches a symlinked parent
|
|
144
|
+
# directory pointing outside the root even for a brand new file.
|
|
145
|
+
existing = normalized
|
|
146
|
+
missing_tail: list[str] = []
|
|
147
|
+
while not existing.exists():
|
|
148
|
+
if existing.parent == existing:
|
|
149
|
+
raise PathEscapeError(f"No existing ancestor found for: {combined}")
|
|
150
|
+
missing_tail.insert(0, existing.name)
|
|
151
|
+
existing = existing.parent
|
|
152
|
+
|
|
153
|
+
if missing_tail and not existing.is_dir():
|
|
154
|
+
# The nearest existing ancestor is a file, not a directory —
|
|
155
|
+
# e.g. candidate was "some-file.txt/nonsense/more.txt". Not a
|
|
156
|
+
# containment issue, but a file can't have children, so this
|
|
157
|
+
# is never a valid path; raise a clear error here rather than
|
|
158
|
+
# returning a nonsensical resolved path and letting a later
|
|
159
|
+
# OS-level call fail with a confusing error instead.
|
|
160
|
+
raise NotADirectoryError(f"Not a directory: {existing}")
|
|
161
|
+
|
|
162
|
+
resolved = existing.resolve(strict=True)
|
|
163
|
+
for part in missing_tail:
|
|
164
|
+
resolved = resolved / part
|
|
165
|
+
return resolved
|
|
166
|
+
|
|
167
|
+
def _is_contained(self, resolved: Path) -> bool:
|
|
168
|
+
# is_relative_to() already returns True when resolved == self.root.
|
|
169
|
+
return resolved.is_relative_to(self.root)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filesystem tool functions. Every one resolves its path(s) through the
|
|
3
|
+
passed-in ConfinedRoot before touching disk — there is deliberately no
|
|
4
|
+
way to call these with a raw, unvalidated path, so a future function
|
|
5
|
+
added here can't forget the check the way CVE-2026-27735 did for git.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
|
|
14
|
+
from .confined_path import ConfinedRoot, PathEscapeError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fs_read_file(root: ConfinedRoot, path: str) -> str:
|
|
18
|
+
resolved = root.resolve(path, must_exist=True)
|
|
19
|
+
if resolved.is_dir():
|
|
20
|
+
raise IsADirectoryError(f"Not a file: {path}")
|
|
21
|
+
return resolved.read_text(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fs_write_file(root: ConfinedRoot, path: str, content: str) -> None:
|
|
25
|
+
resolved = root.resolve(path, must_exist=False)
|
|
26
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
resolved.write_text(content, encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fs_list_directory(root: ConfinedRoot, path: str = ".") -> list[dict]:
|
|
31
|
+
resolved = root.resolve(path, must_exist=True)
|
|
32
|
+
if not resolved.is_dir():
|
|
33
|
+
raise NotADirectoryError(f"Not a directory: {path}")
|
|
34
|
+
return [
|
|
35
|
+
{"name": entry.name, "type": "directory" if entry.is_dir() else "file"}
|
|
36
|
+
for entry in sorted(resolved.iterdir(), key=lambda e: e.name)
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fs_move_file(root: ConfinedRoot, source: str, destination: str) -> None:
|
|
41
|
+
resolved_source = root.resolve(source, must_exist=True)
|
|
42
|
+
resolved_destination = root.resolve(destination, must_exist=False)
|
|
43
|
+
if resolved_destination.is_dir():
|
|
44
|
+
# shutil.move's "move *into* an existing directory" behavior
|
|
45
|
+
# would silently relocate the file to destination/<basename>
|
|
46
|
+
# instead of the literal destination path the caller asked
|
|
47
|
+
# for — reject instead of surprising the caller about where
|
|
48
|
+
# the file actually landed.
|
|
49
|
+
raise IsADirectoryError(
|
|
50
|
+
f"Destination already exists as a directory: {destination}"
|
|
51
|
+
)
|
|
52
|
+
resolved_destination.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
shutil.move(str(resolved_source), str(resolved_destination))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fs_search_files(root: ConfinedRoot, pattern: str, path: str = ".") -> list[str]:
|
|
57
|
+
resolved = root.resolve(path, must_exist=True)
|
|
58
|
+
if not resolved.is_dir():
|
|
59
|
+
raise NotADirectoryError(f"Not a directory: {path}")
|
|
60
|
+
|
|
61
|
+
# Deliberately os.walk(followlinks=False) rather than Path.rglob():
|
|
62
|
+
# not descending into symlinked subdirectories is a guarantee this
|
|
63
|
+
# project needs to hold, not an incidental default — rglob's
|
|
64
|
+
# symlink behavior has differed across Python versions, and this
|
|
65
|
+
# project supports 3.10+, so relying on "whichever pathlib happens
|
|
66
|
+
# to be installed does the safe thing" isn't good enough here.
|
|
67
|
+
matches = []
|
|
68
|
+
for dirpath, dirnames, filenames in os.walk(str(resolved), followlinks=False):
|
|
69
|
+
for name in filenames:
|
|
70
|
+
if fnmatch.fnmatch(name, pattern):
|
|
71
|
+
candidate = os.path.join(dirpath, name)
|
|
72
|
+
try:
|
|
73
|
+
# Re-validate every match through the root anyway —
|
|
74
|
+
# keeps the same "everything goes through
|
|
75
|
+
# ConfinedRoot" invariant as every other tool. Only
|
|
76
|
+
# a path-escape or an OS-level access problem should
|
|
77
|
+
# exclude a match — anything else is a real bug and
|
|
78
|
+
# should surface, not vanish into a silently shorter
|
|
79
|
+
# result list.
|
|
80
|
+
resolved_candidate = root.resolve(candidate, must_exist=True)
|
|
81
|
+
except (PathEscapeError, OSError):
|
|
82
|
+
continue
|
|
83
|
+
matches.append(str(resolved_candidate.relative_to(root.root)))
|
|
84
|
+
return sorted(matches)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git tool functions, built on dulwich — a pure-Python git implementation
|
|
3
|
+
with no subprocess-based content filtering and no argv built from user
|
|
4
|
+
input, instead of shelling out to the `git` binary the way the official
|
|
5
|
+
git MCP server does.
|
|
6
|
+
|
|
7
|
+
Why that matters here specifically: CVE-2025-68144 was argument
|
|
8
|
+
injection because user-controlled values were passed straight into a
|
|
9
|
+
`git` CLI invocation, and the documented RCE chain (`git_init` in a
|
|
10
|
+
writable dir -> a malicious `.git/config` clean filter -> `.gitattributes`
|
|
11
|
+
applies it -> `git_add` triggers the filter -> arbitrary shell command)
|
|
12
|
+
depends entirely on an external `git` process being invoked to run that
|
|
13
|
+
filter. dulwich's blob-normalizer path used by add/checkin doesn't shell
|
|
14
|
+
out, so that specific chain is closed. Two things dulwich *does* still do
|
|
15
|
+
that this module explicitly guards against:
|
|
16
|
+
|
|
17
|
+
1. **`core.worktree` redirection.** `.git/config` can contain a
|
|
18
|
+
`core.worktree` entry that dulwich honors on `Repo.open()`, and every
|
|
19
|
+
`porcelain.*` call re-opens a `Repo` from whatever path string it's
|
|
20
|
+
given — internally, not something a caller can intercept. Since
|
|
21
|
+
`.git/config` is just a text file inside the confined root,
|
|
22
|
+
`fs_write` (which has no special handling for `.git/*`) lets a caller
|
|
23
|
+
write one, and *every git tool in this module* would then silently
|
|
24
|
+
operate against an arbitrary directory outside the confined root
|
|
25
|
+
without the containment check ever seeing it — a full read/write
|
|
26
|
+
escape using nothing but this server's own exposed tools. `_open_repo`
|
|
27
|
+
below refuses any `.git/config` containing `core.worktree`, and
|
|
28
|
+
independently double-checks that the `Repo` it actually opened
|
|
29
|
+
reports its working path as the exact directory that was validated.
|
|
30
|
+
2. **Hooks.** `porcelain.commit()` runs `pre-commit`/`commit-msg`/
|
|
31
|
+
`post-commit` hooks via `subprocess.call()` if they exist and are
|
|
32
|
+
executable at `.git/hooks/*` — real process execution, unrelated to
|
|
33
|
+
content filtering. `git_commit` below always passes `no_verify=True`
|
|
34
|
+
to skip them categorically, rather than relying on them happening to
|
|
35
|
+
not be runnable.
|
|
36
|
+
|
|
37
|
+
Every path this module touches — the repo itself, and every path handed
|
|
38
|
+
to git_add — is also validated through the same ConfinedRoot used by
|
|
39
|
+
fs.py. This is the direct fix for CVE-2026-27735, where GitPython's
|
|
40
|
+
`repo.index.add()` didn't enforce working-tree boundaries for `../`
|
|
41
|
+
paths: here, each path is resolved and checked against the confined
|
|
42
|
+
root *before* it's handed to dulwich at all.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import io
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
from dulwich import porcelain
|
|
51
|
+
from dulwich.repo import Repo
|
|
52
|
+
|
|
53
|
+
from .confined_path import ConfinedRoot
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NotAGitRepositoryError(ValueError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UnsafeRepositoryError(ValueError):
|
|
61
|
+
"""Raised when a repo's own .git/config tries to redirect operations
|
|
62
|
+
outside the confined root — see module docstring, point 1."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def git_init(root: ConfinedRoot, repo_path: str) -> dict:
|
|
66
|
+
resolved = root.resolve(repo_path, must_exist=False)
|
|
67
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
porcelain.init(str(resolved))
|
|
69
|
+
return {"initialized": str(resolved.relative_to(root.root)) or "."}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _open_repo(root: ConfinedRoot, repo_path: str) -> Repo:
|
|
73
|
+
resolved = root.resolve(repo_path, must_exist=True)
|
|
74
|
+
if not (resolved / ".git").exists():
|
|
75
|
+
raise NotAGitRepositoryError(f"Not a git repository: {repo_path}")
|
|
76
|
+
|
|
77
|
+
config_path = resolved / ".git" / "config"
|
|
78
|
+
if config_path.is_file():
|
|
79
|
+
config_text = config_path.read_text(encoding="utf-8", errors="replace")
|
|
80
|
+
if "worktree" in config_text.lower():
|
|
81
|
+
raise UnsafeRepositoryError(
|
|
82
|
+
f"Refusing to operate on {repo_path}: its .git/config sets "
|
|
83
|
+
"core.worktree, which can redirect git operations outside "
|
|
84
|
+
"the confined root."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
repo = Repo(str(resolved))
|
|
88
|
+
# Defense in depth beyond the text check above: confirm dulwich's own
|
|
89
|
+
# notion of the working path, after fully opening and parsing the
|
|
90
|
+
# config, is still exactly the directory we validated — not
|
|
91
|
+
# wherever some other config mechanism might have pointed it.
|
|
92
|
+
actual_path = Path(repo.path).resolve()
|
|
93
|
+
if actual_path != resolved:
|
|
94
|
+
repo.close()
|
|
95
|
+
raise UnsafeRepositoryError(
|
|
96
|
+
f"Refusing to operate on {repo_path}: dulwich resolved its "
|
|
97
|
+
f"working path to {actual_path}, not the confined {resolved}."
|
|
98
|
+
)
|
|
99
|
+
return repo
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def git_status(root: ConfinedRoot, repo_path: str = ".") -> dict:
|
|
103
|
+
repo = _open_repo(root, repo_path)
|
|
104
|
+
status = porcelain.status(repo)
|
|
105
|
+
staged = {
|
|
106
|
+
kind: [p.decode() if isinstance(p, bytes) else p for p in paths]
|
|
107
|
+
for kind, paths in status.staged.items()
|
|
108
|
+
if paths
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
"staged": staged,
|
|
112
|
+
"unstaged": [p.decode() if isinstance(p, bytes) else p for p in status.unstaged],
|
|
113
|
+
"untracked": [p.decode() if isinstance(p, bytes) else p for p in status.untracked],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def git_add(root: ConfinedRoot, repo_path: str, paths: list[str]) -> dict:
|
|
118
|
+
"""Stage files. Every path is resolved and validated against the
|
|
119
|
+
confined root — not just against the repo directory — before being
|
|
120
|
+
handed to dulwich. See module docstring for the CVE this fixes."""
|
|
121
|
+
repo = _open_repo(root, repo_path)
|
|
122
|
+
repo_dir = Path(repo.path)
|
|
123
|
+
validated: list[str] = []
|
|
124
|
+
for p in paths:
|
|
125
|
+
candidate = repo_dir / p
|
|
126
|
+
root.resolve(candidate, must_exist=True) # raises PathEscapeError if it escapes
|
|
127
|
+
validated.append(p)
|
|
128
|
+
porcelain.add(repo, paths=validated)
|
|
129
|
+
return {"added": validated}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def git_commit(root: ConfinedRoot, repo_path: str, message: str, author: str) -> dict:
|
|
133
|
+
repo = _open_repo(root, repo_path)
|
|
134
|
+
author_bytes = author.encode("utf-8")
|
|
135
|
+
sha = porcelain.commit(
|
|
136
|
+
repo,
|
|
137
|
+
message=message,
|
|
138
|
+
author=author_bytes,
|
|
139
|
+
committer=author_bytes,
|
|
140
|
+
no_verify=True, # never run pre-commit/commit-msg/post-commit hooks
|
|
141
|
+
)
|
|
142
|
+
return {"commit": sha.decode("ascii")}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def git_diff(root: ConfinedRoot, repo_path: str = ".", staged: bool = False) -> str:
|
|
146
|
+
repo = _open_repo(root, repo_path)
|
|
147
|
+
buf = io.BytesIO()
|
|
148
|
+
porcelain.diff(repo, staged=staged, outstream=buf)
|
|
149
|
+
return buf.getvalue().decode("utf-8", errors="replace")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def git_log(root: ConfinedRoot, repo_path: str = ".", max_entries: int = 10) -> str:
|
|
153
|
+
repo = _open_repo(root, repo_path)
|
|
154
|
+
buf = io.StringIO()
|
|
155
|
+
porcelain.log(repo, max_entries=max_entries, outstream=buf)
|
|
156
|
+
return buf.getvalue()
|