From c0d6493219472eb0cd5651422d6061a228e7cc15 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 10 Jun 2026 12:44:46 -0700 Subject: [PATCH] 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. --- core/epistemic_disclosure/ask_handle.py | 22 +++++++++++++++++- tests/test_ask_handle.py | 31 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/core/epistemic_disclosure/ask_handle.py b/core/epistemic_disclosure/ask_handle.py index f7299da1..60cd62b6 100644 --- a/core/epistemic_disclosure/ask_handle.py +++ b/core/epistemic_disclosure/ask_handle.py @@ -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(): diff --git a/tests/test_ask_handle.py b/tests/test_ask_handle.py index 346a89ec..edcaf165 100644 --- a/tests/test_ask_handle.py +++ b/tests/test_ask_handle.py @@ -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?")