fix(ask): canonicalize question/proposal path collision check

Compare resolved canonical Path values, not raw string spellings, when
detecting a question_path / proposal_path collision. A relative
proposal_path and an absolute question_path that name the same file now
fail closed with reason "path_collision" instead of resolving.

Adds a regression test for the absolute-vs-relative same-file collision
and keeps the existing exact-string collision test. The new test is
non-vacuous: it fails under the prior raw-string comparison.

Scope: ask_handle.py seam + its test only. No runtime.py, CLAIMS,
metrics, telemetry, schema, or lane-pin changes.
This commit is contained in:
Shay 2026-06-10 12:44:46 -07:00
parent 41664ce60d
commit c0d6493219
2 changed files with 52 additions and 1 deletions

View file

@ -124,6 +124,24 @@ def _rejected(reason: str) -> AskHandleResolution:
return AskHandleResolution(resolved=False, reason=reason, candidate=None)
def _paths_name_same_file(question_path: Path, proposal_path_value: str) -> bool:
"""Canonical-path collision check for the question/proposal pair.
Compares *resolved* canonical paths, not raw string spellings, so an absolute
``question_path`` and a relative ``proposal_path`` (or any two differently
spelled paths) that name the same file collide and fail closed. Resolution is
best-effort (``strict=False``); if a path cannot be canonicalized (e.g. a
symlink loop), fall back to the raw-spelling comparison, which still catches
the exact-string collision.
"""
try:
return question_path.resolve(strict=False) == Path(proposal_path_value).resolve(
strict=False
)
except (OSError, RuntimeError, ValueError):
return str(question_path) == str(proposal_path_value)
def _recompute_content_hash(payload: Any) -> str | None:
"""Recompute the producer's content address from the artifact body.
@ -190,7 +208,9 @@ def resolve_served_ask_handle(config: Any, handle: Any) -> AskHandleResolution:
# Not the producer's content-addressed filename for this hash — the
# handle does not name a producer-written artifact identity.
return _rejected("handle_address_mismatch")
if proposal_path_value is not None and str(question_path) == str(proposal_path_value):
if proposal_path_value is not None and _paths_name_same_file(
question_path, proposal_path_value
):
return _rejected("path_collision")
if not question_path.is_file():

View file

@ -14,6 +14,7 @@ import ast
import dataclasses
import hashlib
import json
import os
from pathlib import Path
from core.config import RuntimeConfig
@ -267,6 +268,36 @@ def test_question_path_equal_to_proposal_path_fails_closed(tmp_path: Path) -> No
assert resolution.reason == "path_collision"
def test_relative_proposal_path_same_file_as_absolute_question_path_fails_closed(
tmp_path: Path,
) -> None:
"""A relative ``proposal_path`` and an absolute ``question_path`` that name the
same file must collide and fail closed the check compares canonical paths,
not raw string spellings, so differing spellings of one file cannot slip past.
"""
payload = _valid_payload()
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
assert path.is_absolute()
# A genuinely relative spelling (from cwd) of the very same artifact file.
relative_proposal = Path(os.path.relpath(path, Path.cwd()))
assert not relative_proposal.is_absolute()
assert relative_proposal.resolve(strict=False) == path.resolve(strict=False)
assert str(relative_proposal) != str(path)
handle = AskArtifactHandle(
question_path=str(path),
content_hash=digest,
proposal_path=str(relative_proposal),
)
resolution = resolve_served_ask_handle(
RuntimeConfig(ask_serving_enabled=True), handle
)
assert resolution.resolved is False
assert resolution.reason == "path_collision"
def test_stale_or_replaced_artifact_fails_closed(tmp_path: Path) -> None:
"""The file at the handle's path no longer re-hashes to the handle's address."""
original = _valid_payload("How many crates are left?")