Skip to content

Commit 902f543

Browse files
mnriemCopilot
andauthored
Harden command registration path handling (#3088)
* fix: validate command 'file' field against path traversal in registrar CommandRegistrar.register_commands() read each command body from source_dir / cmd_file without validating the manifest 'file' field, unlike the parallel skill and preset readers which already reject absolute paths and '..' traversal. A malicious extension/preset/bundle manifest with file: ../../../etc/passwd (or an absolute path) could read arbitrary host files verbatim into a generated agent command at a predictable path (GHSA-w5fv-7w9x-7fc5, CWE-22). Add the same containment guard at the command read site and reject a traversal/absolute 'file' at manifest-load time in ExtensionManifest._validate() for defense-in-depth, plus regression tests for both the read path and the manifest validator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test/fix: address review — robust absolute-path test and tolerant reads - register_commands(): use is_file() instead of exists() and skip the command if read_text() raises (directory or non-UTF8 file), aligning with the other command/skill readers. - Traversal tests: point the absolute-path payload at the real temp secret.txt (guaranteed to exist on all platforms) instead of /etc/passwd, so the absolute-path guard is genuinely exercised and the test fails if it regresses, rather than passing because the target happens not to exist (e.g. on Windows runners). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: rename traversal fixtures to avoid CodeQL secret-storage false positive The regression fixtures named an out-of-tree file secret.txt with TOP-SECRET-CREDENTIAL content. CodeQL's clear-text-storage heuristic treated that read content as sensitive and followed the static path into the pre-existing write_text sinks in _write_registered_output, raising false 'clear-text storage of sensitive information' alerts on PR 3088. Rename the fixtures to neutral outside.txt / OUTSIDE-FILE-MARKER and drop /etc/passwd payloads; the test semantics (a file outside source_dir must never be read into a generated command) are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject Windows drive-relative 'file' values in traversal guards is_absolute() is False for Windows drive-relative paths like C:outside.txt, which contain no '..' yet resolve against the process CWD on that drive — bypassing the containment guard on Windows. Evaluate the 'file' value under PureWindowsPath as well so both the registrar runtime guard and the manifest-load validator reject drive letters (and backslash '..' segments) cross-platform. Extend the regression tests with drive-relative cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use anchor under both path flavors so POSIX-absolute is rejected on Windows On a Windows runner WindowsPath('/abs/outside.md').is_absolute() is False (no drive), so the prior native-Path check let a leading-slash 'file' value through and the manifest validator did not raise. Evaluate the value under both PurePosixPath and PureWindowsPath and reject any non-empty anchor — covering POSIX-absolute, Windows drive-relative, Windows absolute, and rooted-without-drive — in both the registrar guard and the manifest validator. The registrar join now uses the raw 'file' string so native separators are handled by the resolve()/relative_to() containment check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: validate command 'file' field against path traversal in registrar CommandRegistrar.register_commands() read each command body from source_dir / cmd_file without validating the manifest 'file' field, unlike the parallel skill and preset readers which already reject absolute paths and '..' traversal. A malicious extension/preset/bundle manifest with file: ../../../etc/passwd (or an absolute path) could read arbitrary host files verbatim into a generated agent command at a predictable path (GHSA-w5fv-7w9x-7fc5, CWE-22). Add the same containment guard at the command read site and reject a traversal/absolute 'file' at manifest-load time in ExtensionManifest._validate() for defense-in-depth, plus regression tests for both the read path and the manifest validator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test/fix: address review — robust absolute-path test and tolerant reads - register_commands(): use is_file() instead of exists() and skip the command if read_text() raises (directory or non-UTF8 file), aligning with the other command/skill readers. - Traversal tests: point the absolute-path payload at the real temp secret.txt (guaranteed to exist on all platforms) instead of /etc/passwd, so the absolute-path guard is genuinely exercised and the test fails if it regresses, rather than passing because the target happens not to exist (e.g. on Windows runners). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: rename traversal fixtures to avoid CodeQL secret-storage false positive The regression fixtures named an out-of-tree file secret.txt with TOP-SECRET-CREDENTIAL content. CodeQL's clear-text-storage heuristic treated that read content as sensitive and followed the static path into the pre-existing write_text sinks in _write_registered_output, raising false 'clear-text storage of sensitive information' alerts on PR 3088. Rename the fixtures to neutral outside.txt / OUTSIDE-FILE-MARKER and drop /etc/passwd payloads; the test semantics (a file outside source_dir must never be read into a generated command) are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject Windows drive-relative 'file' values in traversal guards is_absolute() is False for Windows drive-relative paths like C:outside.txt, which contain no '..' yet resolve against the process CWD on that drive — bypassing the containment guard on Windows. Evaluate the 'file' value under PureWindowsPath as well so both the registrar runtime guard and the manifest-load validator reject drive letters (and backslash '..' segments) cross-platform. Extend the regression tests with drive-relative cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use anchor under both path flavors so POSIX-absolute is rejected on Windows On a Windows runner WindowsPath('/abs/outside.md').is_absolute() is False (no drive), so the prior native-Path check let a leading-slash 'file' value through and the manifest validator did not raise. Evaluate the value under both PurePosixPath and PureWindowsPath and reject any non-empty anchor — covering POSIX-absolute, Windows drive-relative, Windows absolute, and rooted-without-drive — in both the registrar guard and the manifest validator. The registrar join now uses the raw 'file' string so native separators are handled by the resolve()/relative_to() containment check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: harden register_commands inputs and tighten manifest 'file' validation Address review feedback on #3088: - register_commands(): skip non-string/empty 'file' values instead of raising TypeError, and hoist source_dir.resolve() out of the per-command loop. - ExtensionManifest._validate(): reject 'file' values with leading/trailing whitespace with a clear ValidationError instead of a confusing missing-file failure later. - tests: add non-string 'file' and whitespace cases; use yaml.safe_dump with explicit utf-8 encoding in the manifest validation test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: align runtime '..' policy, correct comment, dedupe test helper Address review feedback on #3088: - register_commands(): also reject '..' segments under both POSIX and Windows semantics, keeping runtime policy consistent with ExtensionManifest._validate() and the skill/preset readers (not just relying on the resolve()/relative_to() containment backstop). - Replace the version-dependent is_absolute() claim in the extensions.py comment with the actual portability rationale (native Path is OS- dependent; C:foo is anchored but not absolute). - Extract the duplicated leak-detection assertion into _assert_no_marker_leak() and add an in-bounds '..' payload that exercises the new runtime '..' rejection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Extract shared path-safety policy and warn on unreadable command files Introduce relative_extension_path_violation() in _utils.py as the single source of truth for the extension-relative `file` path-safety policy, and use it from both the runtime registrar guard (agents.py) and the manifest-load validator (extensions.py) so the two cannot drift. Warn (instead of silently skipping) when an in-bounds command file exists but cannot be read/decoded, surfacing misconfigured extensions. Add unit tests for the shared helper, a read-skip warning test, and make the in-bounds `..` test create its target file so the skip is attributable to the `..` rejection rather than file absence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Retrigger CI Empty commit to re-trigger code scanning / CodeQL analysis on the PR merge ref. Assisted-by: GitHub Copilot CLI (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f9c6cf8 commit 902f543

5 files changed

Lines changed: 295 additions & 7 deletions

File tree

‎src/specify_cli/_utils.py‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,52 @@
99
import subprocess
1010
import tempfile
1111
import yaml
12-
from pathlib import Path
12+
from pathlib import Path, PurePosixPath, PureWindowsPath
1313
from typing import Any
1414
from ._console import console
1515

1616
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
1717
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
1818

1919

20+
def relative_extension_path_violation(value: Any) -> str | None:
21+
"""Return why ``value`` is unsafe as an extension-relative ``file`` path.
22+
23+
Single source of truth for the path-safety policy shared by
24+
``ExtensionManifest._validate()`` (manifest-load validation) and
25+
``CommandRegistrar.register_commands()`` (runtime guard), so the two cannot
26+
drift. Returns a human-readable reason string when ``value`` is unsafe, or
27+
``None`` when it is an acceptable relative path within the extension
28+
directory.
29+
30+
Policy: the value must be a non-empty string with no leading/trailing
31+
whitespace, no absolute/anchored form, and no ``..`` traversal. The value is
32+
evaluated under both POSIX and Windows path semantics because a native
33+
``Path`` is OS-dependent (a ``PurePosixPath`` on POSIX does not interpret
34+
Windows drive/UNC forms, and ``C:foo`` is anchored but not ``is_absolute()``
35+
yet resolves against the CWD on its drive). Rejecting any non-empty anchor
36+
covers POSIX-absolute (``/abs``), Windows drive-relative (``C:foo``), Windows
37+
absolute (``C:\\foo``), and UNC/rooted forms.
38+
"""
39+
if not isinstance(value, str) or not value:
40+
return "must be a non-empty string"
41+
if value.strip() != value:
42+
return "must not have leading or trailing whitespace"
43+
posix_path = PurePosixPath(value)
44+
win_path = PureWindowsPath(value)
45+
if (
46+
posix_path.anchor
47+
or win_path.anchor
48+
or ".." in posix_path.parts
49+
or ".." in win_path.parts
50+
):
51+
return (
52+
"must be a relative path within the extension directory "
53+
"(no absolute paths, drive letters, or '..' segments)"
54+
)
55+
return None
56+
57+
2058
def dump_frontmatter(data: dict[str, Any]) -> str:
2159
"""Serialize skill/command frontmatter to a YAML string.
2260

‎src/specify_cli/agents.py‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import yaml
1717

1818
from ._init_options import is_ai_skills_enabled, load_init_options
19+
from ._utils import relative_extension_path_violation
1920

2021

2122
def _build_agent_configs() -> dict[str, Any]:
@@ -567,17 +568,42 @@ def register_commands(
567568

568569
registered = []
569570
is_cline_ext = agent_name == "cline" and source_id != "core"
571+
source_root = source_dir.resolve()
570572

571573
for cmd_info in commands:
572574
cmd_name = cmd_info["name"]
573575
aliases = cmd_info.get("aliases", [])
574576
cmd_file = cmd_info["file"]
575577

576-
source_file = source_dir / cmd_file
577-
if not source_file.exists():
578+
# Guard against path traversal using the single shared policy in
579+
# relative_extension_path_violation(), so the runtime guard stays
580+
# aligned with ExtensionManifest._validate() and the skill/preset
581+
# readers. Skip a malformed/unsafe ``file`` (non-string, empty,
582+
# whitespace, absolute/anchored, or ``..`` traversal); the
583+
# resolve()/relative_to() check below is the final containment
584+
# backstop.
585+
if relative_extension_path_violation(cmd_file):
586+
continue
587+
try:
588+
source_file = (source_root / cmd_file).resolve()
589+
source_file.relative_to(source_root) # raises ValueError if outside
590+
except (OSError, ValueError):
591+
continue
592+
593+
if not source_file.is_file():
578594
continue
579595

580-
content = source_file.read_text(encoding="utf-8")
596+
try:
597+
content = source_file.read_text(encoding="utf-8")
598+
except (OSError, UnicodeDecodeError) as exc:
599+
import warnings
600+
601+
warnings.warn(
602+
f"Skipping command '{cmd_name}': could not read source file "
603+
f"'{cmd_file}' ({exc.__class__.__name__}: {exc}).",
604+
stacklevel=2,
605+
)
606+
continue
581607
frontmatter, body = self.parse_frontmatter(content)
582608

583609
if frontmatter.get("strategy") == "wrap":

‎src/specify_cli/extensions.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
from ._init_options import is_ai_skills_enabled
3030
from ._invocation_style import is_slash_skills_agent
31-
from ._utils import dump_frontmatter
31+
from ._utils import dump_frontmatter, relative_extension_path_violation
3232
from .catalogs import CatalogEntry as BaseCatalogEntry
3333
from .catalogs import CatalogStackBase
3434

@@ -290,6 +290,18 @@ def _validate(self):
290290
if "name" not in cmd or "file" not in cmd:
291291
raise ValidationError("Command missing 'name' or 'file'")
292292

293+
# Validate the 'file' field at manifest-load time using the single
294+
# shared policy in relative_extension_path_violation(), so manifest
295+
# validation cannot drift from the runtime registrar guard. This is
296+
# defense-in-depth: the command/skill/preset readers also contain
297+
# the resolved path, but rejecting an unsafe value here surfaces a
298+
# clear error instead of silently skipping the command.
299+
cmd_file = cmd["file"]
300+
reason = relative_extension_path_violation(cmd_file)
301+
if reason:
302+
label = repr(cmd_file) if isinstance(cmd_file, str) else f"for command '{cmd.get('name')}'"
303+
raise ValidationError(f"Invalid command 'file' {label}: {reason}")
304+
293305
# Validate command name format
294306
if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]):
295307
corrected = self._try_correct_command_name(cmd["name"], ext["id"])

‎tests/test_extensions.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,40 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data):
377377
with pytest.raises(ValidationError, match="Invalid command name"):
378378
ExtensionManifest(manifest_path)
379379

380+
@pytest.mark.parametrize(
381+
"bad_file",
382+
["../../../outside.md", "../escape.md", "a/../../escape.md", "/abs/outside.md", "C:escape.md", "C:\\Windows\\x.md", "..\\..\\escape.md"],
383+
)
384+
def test_command_file_traversal_rejected(self, temp_dir, valid_manifest_data, bad_file):
385+
"""Manifest 'file' field with traversal/absolute path raises ValidationError.
386+
387+
Defense-in-depth for GHSA-w5fv-7w9x-7fc5.
388+
"""
389+
import yaml
390+
391+
valid_manifest_data["provides"]["commands"][0]["file"] = bad_file
392+
393+
manifest_path = temp_dir / "extension.yml"
394+
with open(manifest_path, 'w', encoding='utf-8') as f:
395+
yaml.safe_dump(valid_manifest_data, f)
396+
397+
with pytest.raises(ValidationError, match="Invalid command 'file'"):
398+
ExtensionManifest(manifest_path)
399+
400+
@pytest.mark.parametrize("bad_file", [" commands/hello.md", "commands/hello.md ", "\tcommands/hello.md"])
401+
def test_command_file_whitespace_rejected(self, temp_dir, valid_manifest_data, bad_file):
402+
"""Manifest 'file' with leading/trailing whitespace raises ValidationError."""
403+
import yaml
404+
405+
valid_manifest_data["provides"]["commands"][0]["file"] = bad_file
406+
407+
manifest_path = temp_dir / "extension.yml"
408+
with open(manifest_path, 'w', encoding='utf-8') as f:
409+
yaml.safe_dump(valid_manifest_data, f)
410+
411+
with pytest.raises(ValidationError, match="leading or trailing whitespace"):
412+
ExtensionManifest(manifest_path)
413+
380414
def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data):
381415
"""Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'."""
382416
import yaml

‎tests/test_registrar_path_traversal.py‎

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from specify_cli.agents import CommandRegistrar
9+
from specify_cli._utils import relative_extension_path_violation
910

1011

1112
TRAVERSAL_PAYLOADS = [
@@ -135,8 +136,185 @@ def test_rejects_traversal_names(self, tmp_path, bad_name):
135136
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
136137

137138

138-
class TestSafeRegistration:
139-
"""Positive regression — well-formed names continue to register."""
139+
ABS_OUTSIDE = "__ABS_OUTSIDE__"
140+
141+
FILE_FIELD_PAYLOADS = [
142+
"../outside.txt",
143+
"../../outside.txt",
144+
"commands/../../outside.txt",
145+
"C:outside.txt",
146+
ABS_OUTSIDE,
147+
]
148+
149+
150+
def _resolve_payload(bad_file: str, outside_file: Path) -> str:
151+
"""Map the absolute-path sentinel to the real, existing outside file.
152+
153+
Using the temp file's own absolute path (instead of ``/etc/passwd``)
154+
guarantees the file exists on every platform — so the test fails if the
155+
absolute-path guard regresses, rather than passing because the target
156+
happens not to exist (e.g. on Windows runners).
157+
"""
158+
return str(outside_file) if bad_file == ABS_OUTSIDE else bad_file
159+
160+
161+
def _assert_no_marker_leak(project: Path, marker: str) -> None:
162+
"""Fail if ``marker`` content was written into any file under ``project``."""
163+
leaked = [
164+
p for p in project.rglob("*")
165+
if p.is_file() and marker in p.read_text(encoding="utf-8", errors="ignore")
166+
]
167+
assert leaked == [], f"Outside file leaked into generated command: {leaked}"
168+
169+
170+
class TestCommandFileTraversal:
171+
"""The manifest ``file`` field must not read files outside source_dir.
172+
173+
Regression for GHSA-w5fv-7w9x-7fc5: ``register_commands`` read
174+
``source_dir / cmd_file`` with no containment check, so a manifest with
175+
a traversal (``file: ../../../outside.txt``) or an absolute path read an
176+
arbitrary host file verbatim into the generated agent command.
177+
"""
178+
179+
@pytest.mark.parametrize("bad_file", FILE_FIELD_PAYLOADS)
180+
def test_claude_skips_traversal_in_file_field(self, tmp_path, bad_file):
181+
project, ext_dir = _project_and_source(tmp_path)
182+
(project / ".claude" / "skills").mkdir(parents=True)
183+
184+
outside_file = tmp_path / "outside.txt"
185+
outside_file.write_text("OUTSIDE-FILE-MARKER", encoding="utf-8")
186+
187+
registrar = CommandRegistrar()
188+
registered = registrar.register_commands(
189+
"claude",
190+
[{"name": "speckit.myext.hello", "file": _resolve_payload(bad_file, outside_file), "aliases": []}],
191+
"myext",
192+
ext_dir,
193+
project,
194+
)
195+
196+
assert registered == []
197+
_assert_no_marker_leak(project, "OUTSIDE-FILE-MARKER")
198+
199+
@pytest.mark.parametrize("bad_file", FILE_FIELD_PAYLOADS)
200+
def test_gemini_skips_traversal_in_file_field(self, tmp_path, bad_file):
201+
project, ext_dir = _project_and_source(tmp_path)
202+
(project / ".gemini" / "commands").mkdir(parents=True)
203+
204+
outside_file = tmp_path / "outside.txt"
205+
outside_file.write_text("OUTSIDE-FILE-MARKER", encoding="utf-8")
206+
207+
registrar = CommandRegistrar()
208+
registered = registrar.register_commands(
209+
"gemini",
210+
[{"name": "speckit.myext.hello", "file": _resolve_payload(bad_file, outside_file), "aliases": []}],
211+
"myext",
212+
ext_dir,
213+
project,
214+
)
215+
216+
assert registered == []
217+
_assert_no_marker_leak(project, "OUTSIDE-FILE-MARKER")
218+
219+
@pytest.mark.parametrize("bad_value", [None, 123, "", ["x"]])
220+
def test_non_string_file_is_skipped(self, tmp_path, bad_value):
221+
"""A non-string/empty ``file`` must be skipped, not raise TypeError."""
222+
project, ext_dir = _project_and_source(tmp_path)
223+
(project / ".gemini" / "commands").mkdir(parents=True)
224+
225+
registrar = CommandRegistrar()
226+
registered = registrar.register_commands(
227+
"gemini",
228+
[{"name": "speckit.myext.hello", "file": bad_value, "aliases": []}],
229+
"myext",
230+
ext_dir,
231+
project,
232+
)
233+
234+
assert registered == []
235+
236+
def test_dotdot_rejected_even_when_target_is_in_bounds(self, tmp_path):
237+
"""An in-bounds ``..`` payload is rejected by the ``..`` check itself.
238+
239+
``commands/../cmd.md`` resolves to ``ext_dir/cmd.md`` — inside
240+
source_dir — so the resolve()/relative_to() containment backstop would
241+
allow it. Creating that target file ensures the command is skipped
242+
because of the ``..`` rejection, not merely because the file is absent.
243+
"""
244+
project, ext_dir = _project_and_source(tmp_path)
245+
(project / ".gemini" / "commands").mkdir(parents=True)
246+
(ext_dir / "cmd.md").write_text(
247+
"---\ndescription: test\n---\n\nbody\n", encoding="utf-8"
248+
)
249+
250+
registrar = CommandRegistrar()
251+
registered = registrar.register_commands(
252+
"gemini",
253+
[{"name": "speckit.myext.hello", "file": "commands/../cmd.md", "aliases": []}],
254+
"myext",
255+
ext_dir,
256+
project,
257+
)
258+
259+
assert registered == []
260+
261+
262+
class TestRelativeExtensionPathPolicy:
263+
"""Unit tests for the shared ``relative_extension_path_violation`` policy."""
264+
265+
@pytest.mark.parametrize(
266+
"value",
267+
[
268+
"commands/hello.md",
269+
"hello.md",
270+
"a/b/c/hello.md",
271+
],
272+
)
273+
def test_safe_relative_paths_have_no_violation(self, value):
274+
assert relative_extension_path_violation(value) is None
275+
276+
@pytest.mark.parametrize(
277+
"value",
278+
[
279+
None,
280+
123,
281+
["x"],
282+
"",
283+
" ",
284+
" hello.md",
285+
"hello.md ",
286+
"/abs/outside.md",
287+
"/etc/passwd",
288+
"C:foo.md",
289+
"C:\\Windows\\system32",
290+
"\\\\server\\share\\x.md",
291+
"../escape.md",
292+
"commands/../../escape.md",
293+
],
294+
)
295+
def test_unsafe_values_report_violation(self, value):
296+
assert relative_extension_path_violation(value) is not None
297+
298+
299+
class TestReadSkipWarning:
300+
"""Unregisterable but in-bounds files warn instead of failing silently."""
301+
302+
def test_unreadable_target_warns_and_skips(self, tmp_path):
303+
project, ext_dir = _project_and_source(tmp_path)
304+
(project / ".gemini" / "commands").mkdir(parents=True)
305+
(ext_dir / "cmd.md").write_bytes(b"\xff\xfe\x00\x80bad")
306+
307+
registrar = CommandRegistrar()
308+
with pytest.warns(UserWarning):
309+
registered = registrar.register_commands(
310+
"gemini",
311+
[{"name": "speckit.myext.hello", "file": "cmd.md", "aliases": []}],
312+
"myext",
313+
ext_dir,
314+
project,
315+
)
316+
317+
assert registered == []
140318

141319
def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path):
142320
"""Lexical check must not block legitimately symlinked sub-directories.

0 commit comments

Comments
 (0)