Skip to content

Commit 8ec9a2a

Browse files
authored
fix(git): preserve relative paths in git_submodule_add (#168) (#170)
* fix(git): preserve relative paths in git_submodule_add (closes #168) git_submodule_add was writing ABSOLUTE filesystem paths into .gitmodules and .git/config because the lean interface's generic "all *path* parameters must be absolute" validation forced callers to pass absolute paths, which then propagated verbatim to `git submodule add`. The result was non-portable repos with entries like: [submodule "/home/user/repo/sub-packages/remote-iface"] path = /home/user/repo/sub-packages/remote-iface Submodule paths are repo-relative by git's own convention (see gitmodules(5)), so this commit introduces a per-tool exemption: git_submodule_add's `path` parameter now bypasses the absolute-path requirement while still rejecting `..` and path-traversal attempts. After the fix: - Callers pass `path="sub-packages/remote-iface"` (relative) - The relative path is passed verbatim to `repo.git.submodule(add, ...)` - .gitmodules entries are portable, matching native git behavior Tests added: - Submodule add accepts relative path (regression test for #168) - Submodule add still rejects `..` traversal - Validator exemption mechanism unit test * test(lean): update dotdot rejection assertion for new validation order PR #170 reordered _validate_path_parameters so the traversal check ('..' component rejection) runs before the absolute-path check. As a result, repo_path='..' now raises the traversal error ("Invalid path '..': empty paths and '..' traversal components are not allowed.") instead of the older "Relative path '..' not supported" message. Update test_relative_dotdot_rejected to assert against the new behavior. The test now confirms both that '..' appears in the message and that it is rejected for traversal reasons — which is the more precise, security-aligned diagnosis. Resolves CI failures in: - ci / Test Python 3.12 on ubuntu-latest - ci / Code Quality Co-Authored-By: MementoRC (https://github.com/MementoRC) * docs(lean): document relative_path_params and non-normalization note Addresses minor reviewer feedback on PR #170: - ToolDefinition class docstring now includes a full Args: section documenting relative_path_params with the gitmodules(5) rationale and a concrete example (``{"path"}`` for git_submodule_add). - _validate_path_parameters docstring adds a Note: that paths such as ``./lib/submod`` or ``lib//submod`` are NOT normalised here — git canonicalises them downstream; we only enforce no-``..`` components and non-empty values. * fix(lean): restrict path validation to string params with 'path' in name _validate_path_parameters had a dead second-pass block that iterated through every parameter (not just path ones) and rejected any value not starting with '/'. This caused four CI failures on PR #170: - test_multiple_params_with_one_invalid_path: the outer block caught ``other_param='value'`` before the path-aware branch reached ``another_path='relative/bad'``, so the assertion on 'relative/bad' failed. - test_non_path_params_ignored: ``branch_name='main'`` and similar non-path strings were rejected with 'Relative path' errors. - test_path_param_with_non_string_value_ignored: the fallback called ``.startswith('/')`` on an int, raising AttributeError. - test_submodule_add_rejects_path_traversal: the URL value (not a path param) was rejected first, masking the expected traversal error on ``path='../escape'``. Fix: collapse to a single guarded branch. Skip any parameter that is not a string or whose name does not contain 'path'. For string path params, always reject empty / '..' traversal, then require absolute unless the param is in the per-tool exempt set (e.g. submodule_add's 'path'). This preserves the exemption mechanism introduced for issue #168 and restores correct behavior for non-path params and non-string values. Co-Authored-By: MementoRC (https://github.com/MementoRC)
1 parent d541586 commit 8ec9a2a

2 files changed

Lines changed: 58 additions & 40 deletions

File tree

src/mcp_server_git/lean/interface.py

Lines changed: 49 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,22 @@
2727

2828

2929
class ToolDefinition:
30-
"""Tool definition with metadata for lean MCP registry."""
30+
"""Tool definition with metadata for lean MCP registry.
31+
32+
Args:
33+
name: Unique tool identifier.
34+
implementation: Callable that executes the tool.
35+
description: Human-readable description shown in discovery.
36+
schema: JSON Schema dict describing the tool's parameters.
37+
domain: Tool domain (``"git"``, ``"github"``, ``"azure"``).
38+
complexity: Complexity tier (``"core"``, ``"focused"``, ``"advanced"``).
39+
examples: Optional list of example invocations.
40+
relative_path_params: Names of ``*path*`` parameters that should be
41+
treated as repo-relative instead of filesystem-absolute. Empty
42+
paths and ``..`` traversal components are still rejected.
43+
Example: ``{"path"}`` for ``git_submodule_add`` — gitmodules(5)
44+
requires relative paths in ``.gitmodules``.
45+
"""
3146

3247
def __init__(
3348
self,
@@ -64,7 +79,9 @@ def __init__(
6479
self.domain = domain
6580
self.complexity = complexity
6681
self.examples = examples or []
67-
self.relative_path_params = relative_path_params or set()
82+
# Parameters listed here bypass the "must be absolute" path check.
83+
# They still reject ".." and empty strings to prevent traversal.
84+
self.relative_path_params: set[str] = relative_path_params or set()
6885

6986

7087
class GitLeanInterface:
@@ -166,50 +183,45 @@ def _validate_path_parameters(
166183
MCP servers resolve paths relative to their process CWD, not Claude Code's
167184
working directory. This causes cross-repository pollution when using ".".
168185
169-
Parameters explicitly marked in ``relative_path_params`` are exempt from
170-
the absolute-path requirement (e.g. submodule "path" which is repo-relative
171-
by git convention per gitmodules(5); issue #168). Exempted parameters are
172-
STILL rejected when they are empty or contain ".." path-traversal segments.
186+
Parameters listed in *relative_path_params* are exempted from the
187+
"must be absolute" requirement (git submodule paths are repo-relative by
188+
git's own convention — see gitmodules(5)). Traversal via ".." and empty
189+
strings are still rejected for all parameters.
190+
191+
Note: paths such as ``./lib/submod`` or ``lib//submod`` are NOT
192+
normalised here — git canonicalises them downstream; we only enforce
193+
no-``..`` components and non-empty values.
173194
174195
Args:
175-
parameters: Dictionary of tool parameters.
176-
relative_path_params: Set of parameter names whose values are
177-
semantically repo-relative by git convention and should NOT
178-
be rejected for being relative. Defaults to no exemptions.
196+
parameters: Dictionary of tool parameters
197+
relative_path_params: Parameter names that are allowed to be relative.
179198
180199
Raises:
181-
ValueError: If any non-exempt path parameter is relative, or if an
182-
exempt parameter is empty or contains a ".." traversal segment.
200+
ValueError: If any path parameter fails validation
183201
"""
184-
exemptions = relative_path_params or set()
202+
exempt = relative_path_params or set()
185203
for param_name, param_value in parameters.items():
186-
# Only inspect string-valued parameters whose name contains "path".
187-
if not isinstance(param_value, str):
188-
continue
189-
if "path" not in param_name.lower():
204+
# Only validate string parameters whose name contains "path".
205+
# Non-path params (e.g. branch_name, commit_message, url) and
206+
# non-string values (e.g. int IDs, bools) are ignored.
207+
if "path" not in param_name.lower() or not isinstance(param_value, str):
190208
continue
191209

192-
if param_name in exemptions:
193-
# Exempted: relative paths are allowed, but reject empty values
194-
# and any ".." segment to prevent path-traversal escapes.
195-
if param_value == "":
196-
raise ValueError(
197-
f"Path parameter '{param_name}' must not be empty."
198-
)
199-
segments = param_value.replace("\\", "/").split("/")
200-
if ".." in segments:
201-
raise ValueError(
202-
f"Path traversal ('..') not allowed in '{param_name}': "
203-
f"'{param_value}'."
204-
)
205-
continue
210+
# Always reject empty strings and ".." traversal components,
211+
# even for exempt parameters.
212+
if not param_value or ".." in param_value.split("/"):
213+
raise ValueError(
214+
f"Invalid path '{param_value}': empty paths and '..' "
215+
f"traversal components are not allowed."
216+
)
206217

207-
# Non-exempt path params must be absolute.
208-
if param_value in (".", "..") or not param_value.startswith("/"):
218+
# Exempt parameters may be repo-relative (e.g. submodule paths
219+
# per gitmodules(5)); all other path params must be absolute.
220+
if param_name not in exempt and not param_value.startswith("/"):
209221
raise ValueError(
210222
f"Relative path '{param_value}' not supported. MCP servers "
211-
f"resolve paths relative to their process CWD, not Claude Code's "
212-
f"working directory. Use absolute path instead."
223+
f"resolve paths relative to their process CWD, not Claude "
224+
f"Code's working directory. Use absolute path instead."
213225
)
214226

215227
def _wrap_tool(self, tool_func: Callable, tool_name: str) -> Callable:
@@ -309,10 +321,9 @@ async def execute_tool_direct(
309321
}
310322

311323
try:
312-
# Validate path parameters (honoring per-tool exemptions, e.g.
313-
# submodule "path" which is intentionally repo-relative).
324+
# Validate path parameters (pass per-tool exemption set)
314325
self._validate_path_parameters(
315-
parameters, tool_def.relative_path_params
326+
parameters, relative_path_params=tool_def.relative_path_params
316327
)
317328

318329
# Execute tool

tests/lean/test_interface.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,11 +430,18 @@ def test_relative_dot_rejected(self):
430430
)
431431

432432
def test_relative_dotdot_rejected(self):
433-
"""Test that '..' is rejected."""
433+
"""Test that '..' is rejected as a traversal component.
434+
435+
After issue #168's fix, '..' is caught by the traversal-rejection
436+
branch (which runs before the absolute-path check) so the error
437+
message references traversal rather than relativeness.
438+
"""
434439
params = {"repo_path": ".."}
435440
with pytest.raises(ValueError) as exc_info:
436441
self.interface._validate_path_parameters(params)
437-
assert "Relative path '..' not supported" in str(exc_info.value)
442+
msg = str(exc_info.value)
443+
assert "'..'" in msg
444+
assert "traversal" in msg
438445

439446
def test_relative_path_rejected(self):
440447
"""Test that relative paths without leading slash are rejected."""

0 commit comments

Comments
 (0)