fix(retrieval): fail-closed on uncanonicalisable paths (embedded null byte)
is_within_dir called os.path.realpath OUTSIDE its try block, so a path with an embedded null byte (which makes realpath raise ValueError, not OSError) leaked that ValueError to callers. Via safe_resolve -> okf._load_file (which catches only PathSecurityError) it propagated uncaught out of navigate_bundle, breaking the OKF SPEC §4 guarantee (okf.py:8-9) that a broken cross-link is silently skipped, never raised: an index link like `](a\x00b.md)` has no `/`, slips past the same-dir pre-filter, and reached path resolution. Move both realpath calls inside the existing try so an uncanonicalisable path is treated as not-within (fail-closed): safe_resolve raises PathSecurityError -> _load_file returns None -> the link is skipped. Fixes the class for both callers of the seam (navigate_bundle + retrieve). Tests (load-bearing — RED before, GREEN after): - test_navigate_skips_null_byte_link: the reported regression. - test_null_byte_path_rejected: the seam directly (is_within_dir/safe_resolve). - test_navigate_skips_bundle_escaping_symlink_link: closes the previously untested path-safety-at-link-resolution branch of navigate_bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HVHLJuwBzp7MXkrUXJYARS
This commit is contained in:
parent
0a11af74a4
commit
3b0ee792bd
3 changed files with 50 additions and 4 deletions
|
|
@ -10,6 +10,7 @@ No ``agent_framework``/``mcp`` import is allowed in ``okf`` — guarded by ``tes
|
|||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -131,6 +132,39 @@ def test_navigate_tolerates_broken_links(tmp_path) -> None:
|
|||
assert names == {"index.md", "real.md"} # missing.md silently skipped, no raise
|
||||
|
||||
|
||||
def test_navigate_skips_null_byte_link(tmp_path) -> None:
|
||||
"""OKF SPEC §4 (okf.py:8-9): a broken link is NEVER raised. A link target carrying an embedded
|
||||
null byte has no path separator, so it slips past the ``/`` pre-filter and reaches path
|
||||
resolution, where ``os.path.realpath`` raises ``ValueError`` — which MUST be absorbed
|
||||
fail-closed (skipped), not propagated out of ``navigate_bundle``."""
|
||||
(tmp_path / "index.md").write_text(
|
||||
"---\ntype: index\n---\n\nSee [bad](a\x00b.md) and [ok](real.md).\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "real.md").write_text("---\ntype: project\n---\n\nbody\n", encoding="utf-8")
|
||||
bundle = okf.navigate_bundle(str(tmp_path)) # must not raise
|
||||
names = {f.name for f in bundle.files}
|
||||
assert names == {"index.md", "real.md"} # null-byte target silently skipped
|
||||
|
||||
|
||||
def test_navigate_skips_bundle_escaping_symlink_link(tmp_path) -> None:
|
||||
"""OKF SPEC §4 + fail-closed path-safety: a same-dir link whose target is a symlink escaping the
|
||||
bundle is skipped (``safe_resolve`` raises ``PathSecurityError`` -> ``_load_file`` -> None),
|
||||
never read. Closes the untested path-safety-at-link-resolution branch of ``navigate_bundle``."""
|
||||
outside = tmp_path / "outside.md"
|
||||
outside.write_text("---\ntype: secret\n---\n\nEXFIL\n", encoding="utf-8")
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "index.md").write_text(
|
||||
"---\ntype: index\n---\n\nSee [escape](evil.md).\n", encoding="utf-8"
|
||||
)
|
||||
os.symlink(str(outside), str(bundle_dir / "evil.md"))
|
||||
bundle = okf.navigate_bundle(str(bundle_dir))
|
||||
names = {f.name for f in bundle.files}
|
||||
assert names == {"index.md"} # escaping symlink target refused, never navigated
|
||||
assert all("EXFIL" not in f.body for f in bundle.files)
|
||||
|
||||
|
||||
def test_load_ir_projection_returns_candidate_ir() -> None:
|
||||
"""The bundle's IR projection (``validator-input.json``) is the candidate measure's cost-IR —
|
||||
the pre-hypothesis ExpeL query key source."""
|
||||
|
|
|
|||
|
|
@ -84,3 +84,12 @@ def test_prefix_collision_sibling_rejected(tmp_path) -> None:
|
|||
intruder = sibling / "secret.txt"
|
||||
intruder.write_text("x", encoding="utf-8")
|
||||
assert is_within_dir(str(intruder), str(root)) is False
|
||||
|
||||
|
||||
def test_null_byte_path_rejected(docs) -> None:
|
||||
# A path carrying an embedded null byte cannot be canonicalised (``os.path.realpath`` raises
|
||||
# ``ValueError``); the seam MUST treat it as not-within / escaping, fail-closed — never leak the
|
||||
# raw ``ValueError`` to callers.
|
||||
assert is_within_dir("a\x00b.txt", str(docs)) is False
|
||||
with pytest.raises(PathSecurityError):
|
||||
safe_resolve(str(docs), "a\x00b.txt")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue