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:
Kjell Tore Guttormsen 2026-07-21 08:00:11 +02:00
commit 3b0ee792bd
3 changed files with 50 additions and 4 deletions

View file

@ -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."""

View file

@ -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")