fix(consume): every read path into a bundle is contained, not just okf_fetch
`okf_fetch` resolved a concept through `connectors.safe_resolve` from the day
the server was written. The other three ways into the same bytes did not.
`okf consume` and `okf_ask` reach `consume.build_payload`, `okf_describe`
reaches `mcp_server.card`, and both built the concept path by joining the
index's own name onto the bundle root. `consume._join` refuses a `..` segment
and an absolute target, but it is a STRING rule over the index text, and a
symlink is a fact about the filesystem that reading that text cannot see: the
index could name `lekkasje.md`, that name could be a link to a file outside the
bundle, and the file came back in the answer.
Measured before the fix, on a bundle carrying one honest concept and one
escaping link: 8 of 11 new rows red, the 3 green ones being `okf_fetch` on the
same two links and the known-positive that the clean bundle still answers. So
the suite was not red for an unrelated reason, and the fix is not "refuse every
bundle holding a link".
One place, not three copies: `consume.resolve_in_bundle` makes the check and
`consume.read_path_in_bundle` adds the file's presence. Every reader here goes
through them -- the index walk, the ref, the document prior, the payload, the
card, `okf_fetch`, and the three outside `consume` (`skill`, `quality`,
`project`) that joined the same way.
Two more failure modes in the same check, because they are the same question:
* A NAMED PIPE is not a regular file. `read_text` on one blocks for as long as
nobody writes to it, which on a server is the whole process; the red row for
it ran 60 s to a subprocess deadline and now returns in under a second.
* A DEAD INDEX LINK raised `FileNotFoundError`, and the broad handler in
`handle` wrote `{error}` into the refusal -- the SERVER's absolute path,
handed to whoever asked, over one index entry naming a file nobody wrote.
It is `concept_unreadable` now, naming the concept and not the machine.
The returned path is the JOINED one, never the resolved one: `read_concept`
derives a concept id by taking the read path relative to the bundle root, and
once containment holds the two are the same bytes.
2334 passed, 2 skipped (was 2323 + 2). `mypy --strict src/` clean over 25 files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
df5a1183c9
commit
bf697bfcad
6 changed files with 310 additions and 15 deletions
|
|
@ -39,6 +39,13 @@ if the bundle's own index names it (`consume.enumerate_concepts`, which
|
|||
refuses a target climbing above the root) AND its resolved path is inside the
|
||||
bundle (`connectors.safe_resolve`, on canonical paths). Either alone would be
|
||||
defensible; the pair is what makes a defect in one of them survivable.
|
||||
|
||||
AND IT IS EVERY READ PATH, not the one tool that happened to have it. Until
|
||||
`consume.resolve_in_bundle` existed, the second check was made by `okf_fetch`
|
||||
alone: `okf_ask` and `okf_describe` joined the index's own name onto the root
|
||||
and opened whatever was there, so a link out of the bundle was read and
|
||||
delivered. The index rule is a STRING rule -- it cannot see a symlink -- which
|
||||
is exactly why one of the two checks is not enough.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -53,7 +60,6 @@ from typing import Any, TextIO
|
|||
|
||||
from . import consume as okf_consume
|
||||
from . import materialize
|
||||
from .connectors import safe_resolve
|
||||
from .errors import SourceError
|
||||
from .profiles import BundleProfile
|
||||
|
||||
|
|
@ -276,7 +282,9 @@ def card(bundle_root: Path, *, profile: BundleProfile, concept_sample: int = 50)
|
|||
concepts = okf_consume.link_parents(
|
||||
[
|
||||
okf_consume.read_concept(
|
||||
bundle_root / f"{concept_id}{profile.paths.concept_suffix}",
|
||||
okf_consume.read_path_in_bundle(
|
||||
bundle_root, f"{concept_id}{profile.paths.concept_suffix}"
|
||||
),
|
||||
bundle_root=bundle_root,
|
||||
root_bundle_id=bundle_id,
|
||||
)
|
||||
|
|
@ -489,10 +497,11 @@ def call_fetch(surface: Surface, arguments: Mapping[str, Any]) -> dict[str, Any]
|
|||
code="concept_unknown",
|
||||
)
|
||||
suffix = surface.profile.paths.concept_suffix
|
||||
try:
|
||||
path = safe_resolve(served.root, f"{concept_id}{suffix}")
|
||||
except SourceError as error:
|
||||
raise ToolError(str(error), code="path_escape") from error
|
||||
# The SECOND of the two independent checks, and since the read paths were
|
||||
# unified it is the same one `okf_ask` and `okf_describe` make. Left as its
|
||||
# own call rather than folded into the index check above: a defect in one
|
||||
# of the two is survivable only while the other is still asked.
|
||||
path = okf_consume.read_path_in_bundle(served.root, f"{concept_id}{suffix}")
|
||||
size = path.stat().st_size
|
||||
if size > MAX_CONCEPT_BYTES:
|
||||
raise ToolError(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue