feat(okf): scan reserved index.md/log.md in mode-b import, not path-reject (review MAJOR #2)

A received OKF bundle MAY legitimately carry index.md (directory listing, read
first under progressive disclosure) and log.md (update history) at any level
(spec §3.1/§6/§7). import_bundle previously hard-rejected those basenames in the
T4 path gate, so a conformant third-party bundle was over-blocked in full
(FAIL_SECURE) — and because the reject fired before scan_concept, index.md's
body (the highest-priority injection surface) was never scanned.

import_bundle now defaults allow_reserved=True: reserved basenames are scanned
as structural files (path-safety checks — traversal / absolute / backslash / .md
— still apply). The shadow-reject (an *upload* masquerading as index.md) is
preserved: the front-end passes allow_reserved=False so a materialized upload
landing on a reserved basename is still refused. That front-end opt-in was
required to keep the shadow-reject once the default flipped (not in the plan's
Filer set; traced from the code).

- okf.py: validate_concept_path/_validate_concept/import_bundle gain the
  keyword; validate_concept_path default stays False (strict standalone).
- tests: +3 (legit index/log admit; injection in index.md body caught;
  okf_version frontmatter admits). Per-concept-iteration test switched to a
  traversal vector; mode-b showcase's index.md surface reframed from
  reserved-name-reject to index.md-body-scan.
- README honest-limits + CLAUDE.md context note the mode-b/upload distinction.

Suite: 341 -> 344 passed. Core invariant intact (dependencies=[]).
This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 06:43:50 +02:00
commit 0772dafb70
6 changed files with 105 additions and 18 deletions

View file

@ -13,8 +13,11 @@ Lexikon-seed: `injection-patterns.mjs` fra `llm-security`-pluginen.
Repoet er på **v0.2 (alpha)**: stdlib-kjernen er bygget og testet (12 moduler + Repoet er på **v0.2 (alpha)**: stdlib-kjernen er bygget og testet (12 moduler +
topp-nivå wiring, showcase + korpus), inkl. OKF-adapter og aktivt-innhold- topp-nivå wiring, showcase + korpus), inkl. OKF-adapter og aktivt-innhold-
detektor (EchoLeak-klassen) i output-gaten. Start med `docs/BRIEF.md` for design, detektor (EchoLeak-klassen) i output-gaten. Mode-b `import_bundle` skanner
`README.md` for bruk, `docs/PLAN.md` for byggerekkefølgen. reserverte strukturfiler (`index.md`/`log.md`) i mottatte bundles i stedet for å
path-avvise dem; upload-front-end beholder shadow-reject (`allow_reserved=False`).
Start med `docs/BRIEF.md` for design, `README.md` for bruk, `docs/PLAN.md` for
byggerekkefølgen.
## Konvensjoner ## Konvensjoner

View file

@ -116,6 +116,12 @@ that a green scan means safe content:
the payload is planted later, when that target is written. A scanner that sees the payload is planted later, when that target is written. A scanner that sees
one document at a time cannot catch it; it needs cross-write graph re-scan one document at a time cannot catch it; it needs cross-write graph re-scan
(tracked for the OKF adapter). (tracked for the OKF adapter).
- **OKF reserved files (`index.md` / `log.md`).** In a *received* bundle these are
legitimate structure (directory listing, update log), so mode-b `import_bundle`
scans their body and frontmatter — an injection planted in a directory listing
is caught — rather than path-rejecting the whole conformant bundle. The upload
front-end keeps the opposite rule: an individual upload landing on a reserved
basename is a shadow of the listing and is refused (`allow_reserved=False`).
- **A document that *describes* attacks is a false positive.** Content whose - **A document that *describes* attacks is a false positive.** Content whose
legitimate purpose is to document prompt-injection payloads (security notes, legitimate purpose is to document prompt-injection payloads (security notes,
this project's own corpus) trips carrier-strip / fail-secure. At the text layer this project's own corpus) trips carrier-strip / fail-secure. At the text layer

View file

@ -151,7 +151,7 @@ def _scannable_regions(frontmatter, body):
return regions return regions
def validate_concept_path(path): def validate_concept_path(path, *, allow_reserved=False):
"""Validate a bundle-relative concept path and return its concept-ID. """Validate a bundle-relative concept path and return its concept-ID.
T4 path / reserved-name gate. The concept-ID is the path with the ``.md`` T4 path / reserved-name gate. The concept-ID is the path with the ``.md``
@ -164,6 +164,13 @@ def validate_concept_path(path):
lets ``Index.md`` shadow ``index.md``; lets ``Index.md`` shadow ``index.md``;
- non-``.md`` files (not a concept document). - non-``.md`` files (not a concept document).
``allow_reserved`` (default ``False``) keeps this a strict concept-path
validator: a reserved basename is not a concept and is rejected. A mode-b
bundle import passes ``allow_reserved=True`` because a *received* bundle MAY
legitimately carry ``index.md`` / ``log.md`` as structural files the caller
then scans their body rather than persisting them as concepts. The path-safety
checks (traversal / absolute / backslash / ``.md``) still apply either way.
Raises :class:`OKFPathError` on any of these; returns the concept-ID string. Raises :class:`OKFPathError` on any of these; returns the concept-ID string.
""" """
if not path or not isinstance(path, str): if not path or not isinstance(path, str):
@ -181,7 +188,7 @@ def validate_concept_path(path):
raise OKFPathError("malformed path segment in %r" % path) raise OKFPathError("malformed path segment in %r" % path)
basename = segments[-1] basename = segments[-1]
if basename.lower() in _RESERVED_BASENAMES: if not allow_reserved and basename.lower() in _RESERVED_BASENAMES:
raise OKFPathError("reserved filename may not name a concept: %r" % basename) raise OKFPathError("reserved filename may not name a concept: %r" % basename)
if not basename.lower().endswith(".md"): if not basename.lower().endswith(".md"):
raise OKFPathError("a concept document must be a .md file: %r" % path) raise OKFPathError("a concept document must be a .md file: %r" % path)
@ -338,7 +345,7 @@ class BundleResult:
return "\n".join(lines) return "\n".join(lines)
def import_bundle(bundle, *, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC): def import_bundle(bundle, *, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC, allow_reserved=True):
"""Validate a received OKF bundle concept-by-concept before merge (mode b). """Validate a received OKF bundle concept-by-concept before merge (mode b).
``bundle`` maps concept path (e.g. ``tables/users.md``) to its raw document ``bundle`` maps concept path (e.g. ``tables/users.md``) to its raw document
@ -348,18 +355,27 @@ def import_bundle(bundle, *, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC):
rejected (FAIL_SECURE) and recorded, but iteration continues, so the caller rejected (FAIL_SECURE) and recorded, but iteration continues, so the caller
sees every issue in the bundle, not only the first. The bundle disposition is sees every issue in the bundle, not only the first. The bundle disposition is
the most severe across its concepts. the most severe across its concepts.
``allow_reserved`` (default ``True``) reflects that this is the mode-b
*received-bundle* path: ``index.md`` / ``log.md`` are legitimate structural
files (OKF spec §3.1/§6/§7) that MAY appear at any level, so they are scanned
(their body is the highest-priority injection surface) rather than
path-rejected over-blocking a conformant third-party bundle is itself a
failure mode (brief principle 5). A front-end materialising individual
*uploads* passes ``allow_reserved=False``: there a reserved basename is a
shadow of the directory listing and must be refused.
""" """
results = tuple( results = tuple(
_validate_concept(path, bundle[path], origin, channel) _validate_concept(path, bundle[path], origin, channel, allow_reserved=allow_reserved)
for path in sorted(bundle) for path in sorted(bundle)
) )
aggregate = _most_severe(r.disposition for r in results) aggregate = _most_severe(r.disposition for r in results)
return BundleResult(results, aggregate, link_graph(bundle)) return BundleResult(results, aggregate, link_graph(bundle))
def _validate_concept(path, doc, origin, channel): def _validate_concept(path, doc, origin, channel, *, allow_reserved=True):
try: try:
concept_id = validate_concept_path(path) concept_id = validate_concept_path(path, allow_reserved=allow_reserved)
except OKFPathError as exc: except OKFPathError as exc:
return ConceptResult(path, None, Disposition.FAIL_SECURE, None, Report(), str(exc)) return ConceptResult(path, None, Disposition.FAIL_SECURE, None, Report(), str(exc))
try: try:

View file

@ -351,6 +351,12 @@ def receive(
REJECT regardless of the guard's aggregate — the guard never saw that drop. REJECT regardless of the guard's aggregate — the guard never saw that drop.
""" """
extracted = extract_inbox(paths, max_entry_bytes=max_entry_bytes, max_total_bytes=max_total_bytes) extracted = extract_inbox(paths, max_entry_bytes=max_entry_bytes, max_total_bytes=max_total_bytes)
result = import_bundle(extracted.bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC) # allow_reserved=False: these are individually-materialized *uploads*, so an
# upload landing on the reserved index.md/log.md is a shadow of the directory
# listing and is refused (T4). A received third-party bundle, by contrast,
# carries those as legitimate structural files (import_bundle's default).
result = import_bundle(
extracted.bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC, allow_reserved=False
)
verdict = "REJECT" if extracted.rejected else _VERDICT[result.disposition] verdict = "REJECT" if extracted.rejected else _VERDICT[result.disposition]
return extracted, result, verdict return extracted, result, verdict

View file

@ -333,11 +333,12 @@ def test_import_bundle_all_clean_warns():
def test_import_bundle_iterates_per_concept_not_whole_unit(): def test_import_bundle_iterates_per_concept_not_whole_unit():
# a reserved-name concept is rejected, but the good concept is still validated # a hard-rejected concept (path traversal) is FAIL_SECURE, but the good
result = import_bundle({"index.md": _CLEAN_A, "tables/users.md": _CLEAN_B}) # concept is still validated — iteration does not stop at the first reject.
result = import_bundle({"../escape.md": _CLEAN_A, "tables/users.md": _CLEAN_B})
by_path = {c.path: c for c in result.concepts} by_path = {c.path: c for c in result.concepts}
assert by_path["index.md"].disposition is Disposition.FAIL_SECURE assert by_path["../escape.md"].disposition is Disposition.FAIL_SECURE
assert by_path["index.md"].error is not None assert by_path["../escape.md"].error is not None
assert by_path["tables/users.md"].error is None assert by_path["tables/users.md"].error is None
assert by_path["tables/users.md"].disposition is Disposition.WARN assert by_path["tables/users.md"].disposition is Disposition.WARN
@ -384,6 +385,58 @@ def test_import_bundle_records_origin_channel_on_stamp():
assert stamp.channel is Channel.MANUAL assert stamp.channel is Channel.MANUAL
# --- A2: reserved structural files (index.md / log.md) in a received bundle ---
# OKF spec §3.1/§6/§7: index.md (directory listing, read FIRST under progressive
# disclosure) and log.md (update history) are legitimate structural files a
# received bundle MAY carry at any level — not concepts, but attacker-controlled
# text. In a mode-b import (the default), import_bundle scans their body (the
# highest-priority injection surface) instead of path-rejecting the whole bundle.
# The shadow-reject — an *upload* masquerading as index.md — stays in the
# front-end/upload context (allow_reserved=False), tested in
# test_okf_inbox_uploads.py.
_CLEAN_INDEX = (
"---\ntype: table\ndescription: A directory listing.\n---\nA clean listing body.\n"
)
_CLEAN_LOG = "---\ntype: table\n---\nA clean change-log entry.\n"
def test_legit_index_and_log_admit():
result = import_bundle(
{"index.md": _CLEAN_INDEX, "log.md": _CLEAN_LOG, "tables/users.md": _CLEAN_A}
)
by_path = {c.path: c for c in result.concepts}
assert by_path["index.md"].error is None # scanned, not path-rejected
assert by_path["log.md"].error is None
assert result.disposition is Disposition.WARN # a clean structural bundle admits
def test_injection_in_index_body_is_caught():
# The coverage hole A2 closes: index.md's body was never scanned (path-rejected
# first). Now an injection planted in the directory listing is caught.
poisoned_index = "---\ntype: table\n---\n" + _INJECTION + "\n"
result = import_bundle({"index.md": poisoned_index, "tables/users.md": _CLEAN_A})
idx = {c.path: c for c in result.concepts}["index.md"]
assert idx.error is None # scanned, not path-rejected
assert any(f.label == "override:ignore-previous" for f in idx.report.findings)
assert result.disposition in (Disposition.QUARANTINE_REVIEW, Disposition.FAIL_SECURE)
def test_index_with_okf_version_frontmatter_admits():
# Risk (review): okf_version frontmatter is legal only in the bundle-root
# index.md. Scanning its body must parse the frontmatter without the strict
# T2 gate tripping on that legitimate key.
result = import_bundle(
{
"index.md": "---\nokf_version: 0.1\n---\n# Concept listing\n",
"tables/users.md": "---\ntype: table\n---\nA clean users table.\n",
}
)
by_path = {c.path: c for c in result.concepts}
assert by_path["index.md"].error is None
assert result.disposition is Disposition.WARN
# --- T5a/A: cross-link extraction, target validation, in-import resolution ---- # --- T5a/A: cross-link extraction, target validation, in-import resolution ----
# OKF links are markdown `.md` paths, bundle-absolute (`/x.md`, recommended) or # OKF links are markdown `.md` paths, bundle-absolute (`/x.md`, recommended) or
# relative (`./x.md`); verified against SPEC.md. In-import graph only (A); the # relative (`./x.md`); verified against SPEC.md. In-import graph only (A); the

View file

@ -3,8 +3,9 @@
The OKF analogue of ``tests/test_showcase.py``: one realistic *received external The OKF analogue of ``tests/test_showcase.py``: one realistic *received external
OKF bundle* that plants one attack per OKF surface at once a body injection, a OKF bundle* that plants one attack per OKF surface at once a body injection, a
frontmatter-``description`` injection, a non-``https`` ``resource:``, a frontmatter-``description`` injection, a non-``https`` ``resource:``, a
path-traversal concept key, a reserved-name shadow, a dangerous frontmatter path-traversal concept key, an injection in a reserved ``index.md`` body, a
value, a dangerous-scheme cross-link, a dangling cross-link, and a dangerous frontmatter value, a dangerous-scheme cross-link, a dangling
cross-link, and a
homoglyph-obfuscated body injection run through the public ``okf`` surface homoglyph-obfuscated body injection run through the public ``okf`` surface
exactly as an "upload inbox" consumer would compose it. Every planted surface is exactly as an "upload inbox" consumer would compose it. Every planted surface is
caught or rejected and the aggregate disposition fails secure. caught or rejected and the aggregate disposition fails secure.
@ -73,8 +74,10 @@ def _poisoned_bundle() -> dict:
"dangerous-frontmatter.md": "---\ntype: &anchor table\n---\nbody\n", "dangerous-frontmatter.md": "---\ntype: &anchor table\n---\nbody\n",
# T4 — a path-traversal concept key escapes the bundle root. # T4 — a path-traversal concept key escapes the bundle root.
"../escape.md": "---\ntype: table\n---\nbody\n", "../escape.md": "---\ntype: table\n---\nbody\n",
# T4 — a reserved basename shadows the directory listing. # A2 — index.md is a legitimate structural file in a *received* bundle
"index.md": "---\ntype: table\ndescription: Listing.\n---\nbody\n", # (not path-rejected in mode-b), but its body IS scanned: an injection
# planted in the directory listing is caught, not silently admitted.
"index.md": "---\ntype: table\ndescription: Listing.\n---\n" + _INJECTION + "\n",
# LLM05 — the zero-click EchoLeak primitive: an auto-fetched markdown # LLM05 — the zero-click EchoLeak primitive: an auto-fetched markdown
# image URL that exfiltrates the moment the concept is rendered. # image URL that exfiltrates the moment the concept is rendered.
"echoleak.md": ( "echoleak.md": (
@ -124,7 +127,7 @@ def _surface_checks(result: BundleResult) -> dict:
desc = by_path.get("frontmatter-desc.md") desc = by_path.get("frontmatter-desc.md")
return { return {
"T4:path-traversal": rejected("../escape.md"), "T4:path-traversal": rejected("../escape.md"),
"T4:reserved-name": rejected("index.md"), "A2:index-body-scanned": caught("index.md", "override:ignore-previous"),
"T3:resource": rejected("bad-resource.md"), "T3:resource": rejected("bad-resource.md"),
"T2:frontmatter": rejected("dangerous-frontmatter.md"), "T2:frontmatter": rejected("dangerous-frontmatter.md"),
"T1:body-injection": caught("body-injection.md", "override:ignore-previous"), "T1:body-injection": caught("body-injection.md", "override:ignore-previous"),