diff --git a/phases/19-capstone-projects/26-sandbox-runner-denylist/code/main.py b/phases/19-capstone-projects/26-sandbox-runner-denylist/code/main.py index 0e349a05a..9db587c4a 100644 --- a/phases/19-capstone-projects/26-sandbox-runner-denylist/code/main.py +++ b/phases/19-capstone-projects/26-sandbox-runner-denylist/code/main.py @@ -215,9 +215,13 @@ def _looks_like_path(arg: str) -> bool: def _check_path_jail(argv: Sequence[str], cfg: SandboxConfig) -> str | None: root = cfg.project_root for arg in argv[1:]: - if not _looks_like_path(arg): + if not arg or arg.startswith("-"): continue - if arg.startswith("-"): + # Also jail-check slashless names that exist under root: a symlink like + # `link.txt` -> /etc/passwd has no path separator, so _looks_like_path + # misses it, yet realpath still escapes the jail. lexists() catches the + # symlink even when its target is missing. + if not _looks_like_path(arg) and not os.path.lexists(os.path.join(root, arg)): continue # Resolve against root if arg is relative; let absolute paths stay absolute. candidate = arg diff --git a/phases/19-capstone-projects/26-sandbox-runner-denylist/code/tests/test_main.py b/phases/19-capstone-projects/26-sandbox-runner-denylist/code/tests/test_main.py index b2063c723..b35d12930 100644 --- a/phases/19-capstone-projects/26-sandbox-runner-denylist/code/tests/test_main.py +++ b/phases/19-capstone-projects/26-sandbox-runner-denylist/code/tests/test_main.py @@ -116,6 +116,23 @@ def test_flag_arg_skipped(self) -> None: reason = _check_path_jail(["echo", "-n"], cfg) self.assertIsNone(reason) + def test_slashless_symlink_to_outside_is_denied(self) -> None: + # A symlink whose name has no slash points outside the root. The module + # advertises a "symlink-safe path jail via realpath prefix check", so the + # jail must resolve and refuse it even though _looks_like_path misses it. + root, cfg = _make_root() + outside_dir = tempfile.mkdtemp(prefix="sandbox-outside-") + secret = os.path.join(outside_dir, "secret.txt") + with open(secret, "w", encoding="utf-8") as fh: + fh.write("TOP-SECRET\n") + try: + os.symlink(secret, os.path.join(root, "link.txt")) + except OSError: + self.skipTest("symlinks not supported on this platform") + reason = _check_path_jail(["cat", "link.txt"], cfg) + self.assertIsNotNone(reason) + self.assertIn("outside project root", reason) + class TruncateTests(unittest.TestCase): def test_under_cap_not_truncated(self) -> None: