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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ .env
7
+ dist/
8
+ build/
@@ -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,4 @@
1
+ """fsguard-mcp — filesystem + git MCP server confined by symlink-resolved
2
+ path containment, not string prefix matching."""
3
+
4
+ __version__ = "0.1.0"
@@ -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()