`tags: [a, b, c]` is the form SPEC.md 4.1's own frontmatter skeleton writes out,
and 9/53 upstream reference concepts use it. It raised on the `[` indicator.
It parses now, to the same list its block-sequence sibling already produced.
The predicate is character-level, inside `_parse_flow_sequence`: an element is a
plain scalar only if it is non-empty and carries none of `{ } [ ] : , " ' #`,
and it then passes the unchanged scalar-indicator rule. Everything that would
need YAML semantics to split or unquote still raises - a quoted element (quotes
are retained here, never stripped), a colon or comma inside an element, a
sequence inside a sequence, an empty element, an anchor, an alias. A sequence
may not mix scalars and mappings, the rule the block list already carries, and
the mixing verdict is reached before the element is parsed so the caller is told
about the mix rather than about a key the allowlist would have named instead.
The 1.3.0 `sources` flow-mapping carrier is unchanged and pinned against
regression. Depth 1 is not spent: the elements are leaves.
Measured with the denominator, against the pinned corpus (`_okf-upstream` @
3fcbb9f, 53 non-reserved documents) and the pinned SPEC (`_okf-canonical` @
ad30107). Baseline reproduced first, with a known-positive control, at 0/53.
After: 6/53, all six in acme_retail. It does not close the corpus - 44/53 still
stop on `generated` written as a top-level block mapping, which spends the
no-nesting-past-depth-1 rule and is a security decision, out of scope here.
P1 alone, per the operator decision of 08.09. The two neighbouring predicates
were measured and deliberately not built: a flush-left block sequence and a
folded plain scalar release 0/53 each on their own, and stacked on this one they
still measure 6/53. `_consume_block_list`, the `description` continuation and
the allowlist are untouched.
docs/LIMITATIONS.md's tags/description entry is rewritten against the
measurement: three of its claims were wrong. The parser does have a
sequence-value type (since 1.3.0 - what it lacks is the indentation the corpus
omits); the figure is 6/53, not the 1.2.0-era 4/53; and tags/description are
not the residual that blocks the corpus. README gains the sequence carrier in
the paragraph that already describes the mapping one.
Self-safety: the predicate compiles no regex, so docs/redos-sweep.py cannot see
it. Measured instead on the CPU clock - linear in element length (exponent
0.86-0.99) and in element count (0.97-1.05) over four doublings to 800_000 -
and pinned by two bounds in tests/test_okf.py.
Six rows that pinned the old refusal are re-aimed at the class that still
holds - the quoted element - the way the 1.3.0 rows were when the carrier
opened. One of them lives in src/llm_ingestion_guard/coverage.py, which is why
the src diff is three files rather than one.
Version 1.4.0 in the code only. The CHANGELOG entry stays under Unreleased and
no tag is cut: README's badge and install pin must keep naming a tag that
exists.
Gates after `git add`: 893 passed (was 868), coverage 130/130 + 6/6 gaps,
redos-sweep exit 0, LIMITATIONS still 45 entries.
1037 lines
42 KiB
Python
1037 lines
42 KiB
Python
"""OKF adapter — Open Knowledge Format (Google, v0.1) support on top of the core.
|
|
|
|
Design principle: the format-agnostic core stays ``text -> findings``. This
|
|
adapter knows OKF structure (frontmatter, paths, links, ``resource``, bundles)
|
|
and feeds scannable text regions into the existing ``sanitize`` / ``scan_output``
|
|
/ ``disposition`` machinery. No YAML/format awareness leaks into the core.
|
|
|
|
T2 — frontmatter parse-safety gate. ``parse_frontmatter`` is a *strict,
|
|
reject-by-default* loader for the minimal OKF frontmatter subset: flat
|
|
``key: value`` scalars, block ``- item`` lists, and one typed, allowlisted
|
|
mapping form (``{ by: x, at: y }`` — see :func:`_parse_flow_mapping`). Every construct the
|
|
"block anchor/alias DoS + dangerous type coercion" requirement names is refused
|
|
*by construction* — you cannot suffer a billion-laughs alias expansion or a
|
|
``!!python/object`` coercion if anchors, aliases and explicit tags are rejected
|
|
before any value is interpreted. This is the "reject, don't parse-then-sanitize"
|
|
philosophy, the frontmatter analogue of the ``resource`` reject-gate (T3).
|
|
|
|
Deliberately NOT a general YAML parser. A security tool whose thesis is
|
|
minimal-dependency should not pull in a full YAML engine whose own features
|
|
(anchors, tags, merges) are the attack surface being defended against. The one
|
|
mapping form it does admit is admitted key-by-key against an allowlist, not
|
|
parsed generally: the mapping class is expressible, never trusted. Quoted
|
|
scalars are kept verbatim (quotes included) rather than unquoted — the value is
|
|
still scanned as text downstream, so an injection inside a quoted value is not
|
|
lost; richer scalar forms are a future refinement, not a silent parse.
|
|
"""
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
from .calibration import MAX_SCAN_CHARS
|
|
from .output import scan_output
|
|
from .report import Report, Source
|
|
from .disposition import Trust, Disposition, Policy, decide
|
|
|
|
__all__ = [
|
|
"parse_frontmatter",
|
|
"scan_concept",
|
|
"validate_concept_path",
|
|
"validate_resource_url",
|
|
"trust_for",
|
|
"stamp_concept",
|
|
"format_log_entry",
|
|
"import_bundle",
|
|
"extract_link_targets",
|
|
"resolve_link",
|
|
"link_graph",
|
|
"Origin",
|
|
"Channel",
|
|
"ProvenanceStamp",
|
|
"ConceptResult",
|
|
"BundleResult",
|
|
"LinkGraphResult",
|
|
"OKFError",
|
|
"OKFFrontmatterError",
|
|
"OKFPathError",
|
|
"OKFResourceError",
|
|
"OKFLinkError",
|
|
]
|
|
|
|
_FENCE = "---"
|
|
_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
|
|
|
|
# A plain OKF scalar cannot *begin* with a YAML structural indicator. Any value
|
|
# starting with one signals an anchor (&), alias (*), explicit tag (!), block
|
|
# scalar (|, >), flow collection ([ ] { }), directive (%) or reserved char
|
|
# (@ `) — all outside the supported subset and all rejected. `{` and `[` are
|
|
# tried as the allowlisted mapping form (G3) and the flow sequence of them (G30)
|
|
# FIRST; they reach this predicate only as a leaf inside one, where a nested
|
|
# collection is refused before it can be read.
|
|
_DANGEROUS_VALUE_STARTS = frozenset("&*!|>[]{}%@`")
|
|
# A quoted scalar is a scalar in YAML however many colons it carries, so the
|
|
# mapping check steps aside for one. The quotes are retained rather than
|
|
# stripped — a pre-existing divergence, pinned in tests/test_okf.py.
|
|
_QUOTE_STARTS = frozenset("\"'")
|
|
|
|
# P1 - what disqualifies a flow-sequence element from being a plain scalar
|
|
# (operator decision, 2026-09-08). Each character is one this parser would have
|
|
# to interpret rather than read: the two quotes (retained, never stripped), the
|
|
# two splitters, the two collection openers and their closers, and the comment
|
|
# indicator. Refusing them is what lets the element be split on commas at the
|
|
# character level without a YAML quote state machine.
|
|
_FLOW_SCALAR_REFUSED = "{}[]:,\"'#"
|
|
|
|
# G3 - the one mapping form T2 can express (operator decision, 2026-08-21).
|
|
# Every key inside a mapping must be on this allowlist: the form is safe because
|
|
# the allowlist inspects each key, not because mappings became trusted. The keys
|
|
# are the ones OKF v0.2 names inside a mapping - `by`/`at` (SPEC.md @ 62432a09
|
|
# §5.2 `generated`/`verified`) and `from`/`to` (§5.1 `usage_window`), plus the
|
|
# §5.1 `sources`-entry labels.
|
|
_MAPPING_KEY_ALLOWLIST = frozenset({
|
|
"by", "at", "from", "to", "id", "title", "author", "usage_count",
|
|
"last_modified",
|
|
})
|
|
|
|
# G30 - the two §5.1 keys admitted inside a `sources` entry and NOWHERE else
|
|
# (operator decision, 2026-09-02). `resource` is REQUIRED within a `sources`
|
|
# entry, so leaving it off left the whole provenance family unwritable; but the
|
|
# same field name in §10 (`executor.resource`, `attester.resource`) names run
|
|
# instructions and code - the door-C route closed in 1.1.0. 1.2.0 argued the
|
|
# parser could not tell the two apart without parent-key context it did not
|
|
# have. That premise was false: the owning key is in scope at every call site
|
|
# below, it was simply never threaded through. It is threaded now, so the
|
|
# discrimination is structural rather than a judgement about the value.
|
|
# `usage_window` is allowlisted here for accuracy of refusal - §5.1 permits it
|
|
# per entry, and it is then refused on the depth rule (a mapping inside a
|
|
# mapping, which this parser admits at no key) rather than refused as if the
|
|
# key were unknown.
|
|
_SOURCES_ENTRY_KEYS = frozenset({"resource", "usage_window"})
|
|
|
|
|
|
def _allowed_mapping_keys(parent_key):
|
|
"""The mapping-key allowlist for a mapping owned by ``parent_key``."""
|
|
if parent_key == "sources":
|
|
return _MAPPING_KEY_ALLOWLIST | _SOURCES_ENTRY_KEYS
|
|
return _MAPPING_KEY_ALLOWLIST
|
|
|
|
|
|
class OKFError(Exception):
|
|
"""Base class for OKF adapter rejections."""
|
|
|
|
|
|
class OKFFrontmatterError(OKFError):
|
|
"""Frontmatter violates the strict, reject-by-default OKF subset."""
|
|
|
|
|
|
class OKFPathError(OKFError):
|
|
"""A concept path is unsafe (traversal, absolute, or reserved-name shadow)."""
|
|
|
|
|
|
class OKFResourceError(OKFError):
|
|
"""A ``resource`` URL is not on the https allowlist."""
|
|
|
|
|
|
class OKFLinkError(OKFError):
|
|
"""A cross-link target is unsafe (dangerous scheme or bundle escape)."""
|
|
|
|
|
|
_URL_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.\-]*):")
|
|
|
|
|
|
# `index.md` (directory listing) and `log.md` (update history) are reserved by
|
|
# the OKF spec and MUST NOT name concept documents — at any directory level.
|
|
_RESERVED_BASENAMES = frozenset({"index.md", "log.md"})
|
|
|
|
|
|
def parse_frontmatter(document):
|
|
"""Split leading OKF frontmatter from the body and parse it strictly.
|
|
|
|
Returns ``(frontmatter: dict, body: str)``. A document with no leading
|
|
``---`` fence has no frontmatter: ``({}, document)`` is returned unchanged.
|
|
|
|
Raises ``OKFFrontmatterError`` on an unterminated fence or any construct
|
|
outside the minimal flat subset (anchors, aliases, explicit tags, merge
|
|
keys, block scalars, flow sequences, nested mappings). The single exception
|
|
is the typed, allowlisted flow mapping (:func:`_parse_flow_mapping`), which
|
|
parses into a ``dict`` of allowlisted keys with plain-scalar leaves — every
|
|
other route to a mapping still raises.
|
|
"""
|
|
lines = document.split("\n")
|
|
if not lines or lines[0].strip() != _FENCE:
|
|
return {}, document
|
|
|
|
close_idx = None
|
|
for i in range(1, len(lines)):
|
|
if lines[i].strip() == _FENCE:
|
|
close_idx = i
|
|
break
|
|
if close_idx is None:
|
|
raise OKFFrontmatterError("unterminated frontmatter: no closing '---' fence")
|
|
|
|
frontmatter = _parse_flat(lines[1:close_idx])
|
|
body = "\n".join(lines[close_idx + 1:])
|
|
return frontmatter, body
|
|
|
|
|
|
def scan_concept(document, *, source=Source.OUTPUT):
|
|
"""Scan every scannable region of one OKF concept, merged into one Report.
|
|
|
|
T1 — whole-concept scan surface. The body is not the only injectable region:
|
|
OKF frontmatter *values* (notably ``description``, which propagates into
|
|
``index.md`` and is read first under progressive disclosure), ``tags`` items
|
|
and the ``resource`` string are all attacker-controlled and must go through
|
|
the same ``scan_output`` path as the body. Findings from all regions are
|
|
merged so nothing in the frontmatter escapes the gate.
|
|
|
|
Frontmatter is parsed with the strict :func:`parse_frontmatter` gate first,
|
|
so a parse-safety violation (T2) raises before any scanning.
|
|
"""
|
|
frontmatter, body = parse_frontmatter(document)
|
|
report = Report()
|
|
for region in _scannable_regions(frontmatter, body):
|
|
report.extend(scan_output(region, source=source).findings)
|
|
return report
|
|
|
|
|
|
def _scannable_regions(frontmatter, body):
|
|
"""The text regions of a concept that carry attacker-controlled content."""
|
|
regions = [body]
|
|
for value in frontmatter.values():
|
|
regions.extend(_value_regions(value))
|
|
return regions
|
|
|
|
|
|
def _value_regions(value):
|
|
"""Every scannable leaf of one frontmatter value.
|
|
|
|
A mapping value (G3) is a new *shape* on this surface, not a new exemption:
|
|
its leaves are scanned exactly like a scalar or a list item, so an injection
|
|
parked in ``generated: { by: ... }`` reaches ``scan_output`` like any other
|
|
frontmatter text. The same holds for a *list* of mappings (G30, ``sources``),
|
|
which this function already flattens through its list branch. Mapping *keys*
|
|
are not scanned because they cannot carry attacker text - the allowlist
|
|
admits a fixed, per-parent name set and nothing else.
|
|
"""
|
|
if isinstance(value, dict):
|
|
return [leaf for leaf in value.values() if leaf]
|
|
if isinstance(value, list):
|
|
regions = []
|
|
for item in value:
|
|
regions.extend(_value_regions(item))
|
|
return regions
|
|
return [value] if value else []
|
|
|
|
|
|
def validate_concept_path(path, *, allow_reserved=False):
|
|
"""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``
|
|
suffix removed (OKF spec). Rejects, before the path is ever used to write:
|
|
|
|
- ``..`` traversal at any segment (escape the bundle);
|
|
- absolute paths (``/...``) and backslashes (platform-separator ambiguity);
|
|
- the reserved basenames ``index.md`` / ``log.md`` (shadow the directory
|
|
listing / update log), case-insensitively — a case-insensitive filesystem
|
|
lets ``Index.md`` shadow ``index.md``;
|
|
- 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.
|
|
"""
|
|
if not path or not isinstance(path, str):
|
|
raise OKFPathError("empty or non-string concept path: %r" % (path,))
|
|
if path.startswith("/"):
|
|
raise OKFPathError("concept path must be bundle-relative, not absolute: %r" % path)
|
|
if "\\" in path:
|
|
raise OKFPathError("backslashes are not permitted in a concept path: %r" % path)
|
|
|
|
segments = path.split("/")
|
|
for seg in segments:
|
|
if seg == "..":
|
|
raise OKFPathError("path traversal ('..') is not permitted: %r" % path)
|
|
if seg == "" or seg == ".":
|
|
raise OKFPathError("malformed path segment in %r" % path)
|
|
|
|
basename = segments[-1]
|
|
if not allow_reserved and basename.lower() in _RESERVED_BASENAMES:
|
|
raise OKFPathError("reserved filename may not name a concept: %r" % basename)
|
|
if not basename.lower().endswith(".md"):
|
|
raise OKFPathError("a concept document must be a .md file: %r" % path)
|
|
|
|
return path[: -len(".md")]
|
|
|
|
|
|
def validate_resource_url(url):
|
|
"""Validate a concept's ``resource`` URL against the https allowlist (T3).
|
|
|
|
The OKF format places no constraint on the ``resource`` scheme (verified
|
|
against SPEC.md), so this default-deny allowlist is the only gate: it
|
|
**rejects** anything that is not ``https`` — ``http``, ``data:``,
|
|
``javascript:``, ``file:``, ``blob:``, ``ftp:`` and schemeless/relative
|
|
strings — *before commit*. This is reject, not defang: ``neutralize`` renders
|
|
dangerous schemes inert for human audit; this refuses to persist them at all.
|
|
|
|
Returns ``url`` unchanged on success; raises :class:`OKFResourceError`
|
|
otherwise.
|
|
"""
|
|
if not url or not isinstance(url, str):
|
|
raise OKFResourceError("empty or non-string resource URL: %r" % (url,))
|
|
stripped = url.strip()
|
|
if " " in stripped or any(ord(c) < 0x20 for c in stripped):
|
|
raise OKFResourceError("resource URL contains whitespace/control chars: %r" % url)
|
|
match = _URL_SCHEME_RE.match(stripped)
|
|
scheme = match.group(1).lower() if match else None
|
|
if scheme != "https":
|
|
raise OKFResourceError(
|
|
"resource URL must use the https scheme (got %r): %r" % (scheme, url)
|
|
)
|
|
return url
|
|
|
|
|
|
class Origin(str, Enum):
|
|
"""Where the data actually came from (brief §5) — drives trust."""
|
|
|
|
EXTERNAL = "external"
|
|
INTERNAL = "internal"
|
|
|
|
|
|
class Channel(str, Enum):
|
|
"""How it was inserted — recorded for the log, but never upgrades trust."""
|
|
|
|
AUTOMATIC = "automatic"
|
|
MANUAL = "manual"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProvenanceStamp:
|
|
"""A per-concept provenance record for ``log.md`` (brief §6 T6).
|
|
|
|
Composes ``Origin`` x ``Channel`` x ``Trust`` x ``Disposition`` — it adds no
|
|
new disposition value (brief §8 naming caveat); the disposition is whatever
|
|
:func:`decide` returns for the concept's scan under its origin-derived trust.
|
|
"""
|
|
|
|
concept_id: str
|
|
origin: Origin
|
|
channel: Channel
|
|
trust: Trust
|
|
disposition: Disposition
|
|
|
|
|
|
def trust_for(origin, channel=None):
|
|
"""Map a concept's origin to a :class:`Trust` tier (brief §5).
|
|
|
|
Trust follows the *origin*, never the insertion *channel*: a manual paste of
|
|
external material is still external. The channel is recorded on the stamp for
|
|
the audit log but grants no trust discount.
|
|
"""
|
|
return Trust.TRUSTED if origin is Origin.INTERNAL else Trust.UNTRUSTED
|
|
|
|
|
|
def stamp_concept(concept_id, report, origin, channel):
|
|
"""Stamp one scanned concept with its provenance and disposition (T6).
|
|
|
|
``report`` is the concept's scan (e.g. from :func:`scan_concept`); the
|
|
disposition is decided under a policy at the origin-derived trust tier.
|
|
"""
|
|
trust = trust_for(origin, channel)
|
|
decision = decide(report, Policy(trust=trust))
|
|
return ProvenanceStamp(concept_id, origin, channel, trust, decision.disposition)
|
|
|
|
|
|
def format_log_entry(stamp, *, timestamp=None):
|
|
"""Render a :class:`ProvenanceStamp` as one tab-separated ``log.md`` line.
|
|
|
|
``timestamp`` is caller-supplied (kept out of the stamp so stamping stays
|
|
deterministic and wall-clock-free); when given it is prepended.
|
|
"""
|
|
fields = [
|
|
stamp.concept_id,
|
|
stamp.origin.value,
|
|
stamp.channel.value,
|
|
stamp.trust.value,
|
|
stamp.disposition.value,
|
|
]
|
|
if timestamp is not None:
|
|
fields.insert(0, timestamp)
|
|
return "\t".join(fields)
|
|
|
|
|
|
# --- T7: bundle-import iterator (mode b) -------------------------------------
|
|
|
|
# WARN < QUARANTINE_REVIEW < FAIL_SECURE — the aggregate is the most severe.
|
|
_DISPOSITION_ORDER = (
|
|
Disposition.WARN,
|
|
Disposition.QUARANTINE_REVIEW,
|
|
Disposition.FAIL_SECURE,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConceptResult:
|
|
"""The outcome of validating one concept in a bundle.
|
|
|
|
``error`` is ``None`` on a concept that passed the gates (and then carries a
|
|
``stamp``); a non-``None`` ``error`` means a hard reject (bad path, unsafe
|
|
frontmatter, or a non-https ``resource``) — ``disposition`` is FAIL_SECURE
|
|
and no stamp is produced, so the concept must not be merged.
|
|
"""
|
|
|
|
path: str
|
|
concept_id: str | None
|
|
disposition: Disposition
|
|
stamp: ProvenanceStamp | None
|
|
report: Report
|
|
error: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BundleResult:
|
|
"""Per-concept results, the aggregate disposition, and the cross-link graph.
|
|
|
|
``links`` is the in-import :class:`LinkGraphResult` for the whole bundle
|
|
(dangling / rejected / resolved edges), so a mode-b import returns both halves
|
|
of the gate together. Whether a dangling or rejected link should block is the
|
|
caller's disposition call (design principle 4).
|
|
"""
|
|
|
|
concepts: tuple
|
|
disposition: Disposition
|
|
links: "LinkGraphResult"
|
|
|
|
def log(self):
|
|
"""The ``log.md`` body — one line per concept, rejected ones marked."""
|
|
lines = []
|
|
for c in self.concepts:
|
|
if c.stamp is not None:
|
|
lines.append(format_log_entry(c.stamp))
|
|
else:
|
|
lines.append("\t".join([c.path, "REJECTED", c.disposition.value, c.error or ""]))
|
|
return "\n".join(lines)
|
|
|
|
|
|
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).
|
|
|
|
``bundle`` maps concept path (e.g. ``tables/users.md``) to its raw document
|
|
text. Each concept runs the full per-concept gate — path/reserved-name (T4),
|
|
frontmatter parse-safety (T2), ``resource`` allowlist (T3), whole-concept
|
|
scan (T1) and provenance stamping (T6). A concept that fails a hard gate is
|
|
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
|
|
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(
|
|
_validate_concept(path, bundle[path], origin, channel, allow_reserved=allow_reserved)
|
|
for path in sorted(bundle)
|
|
)
|
|
aggregate = _most_severe(r.disposition for r in results)
|
|
return BundleResult(results, aggregate, link_graph(bundle))
|
|
|
|
|
|
def _validate_concept(path, doc, origin, channel, *, allow_reserved=True):
|
|
try:
|
|
concept_id = validate_concept_path(path, allow_reserved=allow_reserved)
|
|
except OKFPathError as exc:
|
|
return ConceptResult(path, None, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
|
try:
|
|
frontmatter, _body = parse_frontmatter(doc)
|
|
except OKFFrontmatterError as exc:
|
|
return ConceptResult(path, concept_id, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
|
resource = frontmatter.get("resource")
|
|
if isinstance(resource, str):
|
|
try:
|
|
validate_resource_url(resource)
|
|
except OKFResourceError as exc:
|
|
return ConceptResult(path, concept_id, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
|
report = scan_concept(doc)
|
|
stamp = stamp_concept(concept_id, report, origin, channel)
|
|
return ConceptResult(path, concept_id, stamp.disposition, stamp, report, None)
|
|
|
|
|
|
def _most_severe(dispositions):
|
|
worst = Disposition.WARN
|
|
for disposition in dispositions:
|
|
if _DISPOSITION_ORDER.index(disposition) > _DISPOSITION_ORDER.index(worst):
|
|
worst = disposition
|
|
return worst
|
|
|
|
|
|
# --- T5a / A: cross-link graph (in-import) -----------------------------------
|
|
# The persisted cross-run graph (B) that would catch "plant a link now, write
|
|
# the poisoned target in a LATER run" (§7.2) is deferred to stream 2, where the
|
|
# consumer that owns the corpus decides where the durable graph state lives.
|
|
# This in-import graph resolves links within a single bundle merge.
|
|
|
|
# ReDoS note (OWASP LLM10): the label run excludes `[`, the character that opens
|
|
# this pattern's own anchor. Without it, a bundle body repeating `[` and never
|
|
# closing it makes every start position rescan the tail — 7.1s at 100_000 chars,
|
|
# exponent ~2.0, over attacker-supplied bodies this adapter reads with no input
|
|
# cap. Same defect and same fix as `active_content.MD_LINK_RE`, including the
|
|
# trade it names: a label containing a nested `[...]` is given up on, which costs
|
|
# no exfil coverage because the inner link is matched on its own.
|
|
_MD_LINK_RE = re.compile(r"\[[^\]\[]*\]\(\s*([^)\s]+)")
|
|
# Active-content schemes are refused in a link, mirroring the resource gate (T3).
|
|
_DANGEROUS_LINK_SCHEMES = frozenset({"javascript", "data", "vbscript", "file", "blob"})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LinkGraphResult:
|
|
"""In-import cross-link resolution over one bundle.
|
|
|
|
``dangling`` — ``(from_id, target_concept_id)`` for in-bundle ``.md`` links
|
|
whose target concept is **not present** in the bundle: the dormant-injection
|
|
signal of §7.2 (a link planted to a not-yet-written concept). ``rejected`` —
|
|
``(from_id, target, reason)`` for links refused outright (dangerous scheme or
|
|
bundle escape). ``resolved`` — ``(from_id, target_concept_id)`` for links to
|
|
concepts present in the bundle. ``truncated`` — ``(from_id, body_length)`` for
|
|
bodies read only as far as the scan cap, so a caller can tell "no links past
|
|
here" apart from "no links *read* past here" (OWASP LLM10).
|
|
"""
|
|
|
|
dangling: tuple
|
|
rejected: tuple
|
|
resolved: tuple
|
|
truncated: tuple = ()
|
|
|
|
|
|
def extract_link_targets(body):
|
|
"""Return the destinations of markdown ``[text](target)`` links in ``body``."""
|
|
return _MD_LINK_RE.findall(body)
|
|
|
|
|
|
def resolve_link(target, from_concept_id):
|
|
"""Resolve one link target to an in-bundle concept-ID, or reject it (T5a).
|
|
|
|
Returns the target concept-ID for an in-bundle ``.md`` link (bundle-absolute
|
|
``/x.md`` or relative ``./x.md`` / ``../y.md``, resolved against the linking
|
|
concept's directory). Returns ``None`` for an external ``http(s)``/other
|
|
non-active link (not a concept edge) and for non-``.md`` targets. Raises
|
|
:class:`OKFLinkError` for an active-content scheme or a ``..`` escape past the
|
|
bundle root.
|
|
"""
|
|
candidate = target.strip().split("#", 1)[0].split("?", 1)[0]
|
|
if not candidate:
|
|
return None
|
|
|
|
scheme_match = _URL_SCHEME_RE.match(candidate)
|
|
if scheme_match:
|
|
scheme = scheme_match.group(1).lower()
|
|
if scheme in _DANGEROUS_LINK_SCHEMES:
|
|
raise OKFLinkError("link uses a dangerous scheme %r: %r" % (scheme, target))
|
|
return None # external (http/https/mailto/…): not an in-bundle concept edge
|
|
|
|
if not candidate.endswith(".md"):
|
|
return None # not a concept-document link (asset, anchor, …)
|
|
|
|
if candidate.startswith("/"):
|
|
normalized = _normalize_bundle_path(candidate[1:])
|
|
else:
|
|
from_dir = from_concept_id.rsplit("/", 1)[0] if "/" in from_concept_id else ""
|
|
joined = from_dir + "/" + candidate if from_dir else candidate
|
|
normalized = _normalize_bundle_path(joined)
|
|
|
|
return normalized[: -len(".md")]
|
|
|
|
|
|
def link_graph(bundle, max_scan_chars=MAX_SCAN_CHARS):
|
|
"""Resolve every cross-link in ``bundle`` against the concepts it contains.
|
|
|
|
``bundle`` maps concept path to document text (as :func:`import_bundle`). Only
|
|
the body is scanned for links. See :class:`LinkGraphResult` for the outcome.
|
|
|
|
Self-safety (OWASP LLM10): every body is attacker-supplied and each is walked
|
|
by a `findall`, so each body is capped at ``max_scan_chars`` and recorded in
|
|
``truncated``. It truncates rather than raising, the way the scanners do: the
|
|
graph reports on documents, it does not hand them back, so a shortened scan
|
|
costs edges — not the caller's content.
|
|
"""
|
|
present = {p[: -len(".md")] for p in bundle if p.endswith(".md")}
|
|
dangling, rejected, resolved, truncated = [], [], [], []
|
|
|
|
for path in sorted(bundle):
|
|
if not path.endswith(".md"):
|
|
continue
|
|
from_id = path[: -len(".md")]
|
|
try:
|
|
_frontmatter, body = parse_frontmatter(bundle[path])
|
|
except OKFFrontmatterError:
|
|
body = bundle[path] # unparseable frontmatter is T2's reject, not ours
|
|
|
|
if len(body) > max_scan_chars:
|
|
truncated.append((from_id, len(body)))
|
|
body = body[:max_scan_chars]
|
|
|
|
for target in extract_link_targets(body):
|
|
try:
|
|
concept_id = resolve_link(target, from_id)
|
|
except OKFLinkError as exc:
|
|
rejected.append((from_id, target, str(exc)))
|
|
continue
|
|
if concept_id is None:
|
|
continue
|
|
if concept_id in present:
|
|
resolved.append((from_id, concept_id))
|
|
else:
|
|
dangling.append((from_id, concept_id))
|
|
|
|
return LinkGraphResult(
|
|
tuple(dangling), tuple(rejected), tuple(resolved), tuple(truncated)
|
|
)
|
|
|
|
|
|
def _normalize_bundle_path(path):
|
|
"""Normalize a ``/``-separated bundle path; raise if it escapes the root."""
|
|
parts = []
|
|
for segment in path.split("/"):
|
|
if segment in ("", "."):
|
|
continue
|
|
if segment == "..":
|
|
if not parts:
|
|
raise OKFLinkError("link target escapes the bundle root: %r" % path)
|
|
parts.pop()
|
|
else:
|
|
parts.append(segment)
|
|
return "/".join(parts)
|
|
|
|
|
|
def _parse_flat(fm_lines):
|
|
result = {}
|
|
i = 0
|
|
n = len(fm_lines)
|
|
while i < n:
|
|
raw = fm_lines[i]
|
|
stripped = raw.strip()
|
|
|
|
if stripped == "" or stripped.startswith("#"):
|
|
i += 1
|
|
continue
|
|
|
|
# An indented line with no active list key is a nested structure.
|
|
if raw[:1] in (" ", "\t"):
|
|
raise OKFFrontmatterError(
|
|
"nested mappings are not supported in OKF frontmatter: %r" % raw
|
|
)
|
|
|
|
if stripped.startswith("<<"):
|
|
raise OKFFrontmatterError("YAML merge keys are not permitted")
|
|
|
|
if ":" not in stripped:
|
|
raise OKFFrontmatterError("malformed frontmatter line: %r" % raw)
|
|
|
|
key, _, value = stripped.partition(":")
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if not _KEY_RE.match(key):
|
|
raise OKFFrontmatterError("invalid frontmatter key: %r" % key)
|
|
|
|
if value == "":
|
|
items, i = _consume_block_list(fm_lines, i + 1, key)
|
|
result[key] = items if items is not None else ""
|
|
continue
|
|
|
|
mapping = _parse_flow_mapping(value, key)
|
|
if mapping is not None:
|
|
result[key] = mapping
|
|
i += 1
|
|
continue
|
|
|
|
sequence = _parse_flow_sequence(value, key)
|
|
if sequence is not None:
|
|
result[key] = sequence
|
|
i += 1
|
|
continue
|
|
|
|
_reject_dangerous_value(value)
|
|
_reject_mapping_construct(value)
|
|
result[key] = value
|
|
i += 1
|
|
|
|
return result
|
|
|
|
|
|
def _consume_block_list(fm_lines, start, parent_key=None):
|
|
"""Consume `` - item`` lines following a bare ``key:``.
|
|
|
|
Returns ``(items, next_index)`` - ``items`` is ``None`` (and ``next_index``
|
|
unchanged) when no list item follows, so the caller can treat the key as an
|
|
empty scalar and let the next line trip the nested-structure guard.
|
|
|
|
An item is one of three shapes, decided by the item text alone: a flow
|
|
mapping (G3), a block mapping (G30 - an unquoted ``key: value`` opening a
|
|
run of more-indented sibling entries, which is SPEC.md §5.1's own carrier
|
|
for ``sources``), or a plain scalar. ``parent_key`` is the key that owns the
|
|
list; it decides the mapping-key allowlist, which is how ``sources[].resource``
|
|
is admitted while ``executor``/``attester`` ``resource`` stays refused.
|
|
|
|
A list may not mix scalars and mappings. YAML permits it, but a consumer
|
|
iterating ``sources`` and reading ``entry.get("id")`` gets an
|
|
``AttributeError`` off the first ``str`` - refusing is the cheaper failure.
|
|
"""
|
|
items = []
|
|
kinds = set()
|
|
i = start
|
|
n = len(fm_lines)
|
|
while i < n:
|
|
raw = fm_lines[i]
|
|
stripped = raw.strip()
|
|
if stripped == "" or stripped.startswith("#"):
|
|
i += 1
|
|
continue
|
|
if not (raw[:1] in (" ", "\t") and stripped.startswith("- ")):
|
|
break
|
|
|
|
item = stripped[2:].strip()
|
|
|
|
mapping = _parse_flow_mapping(item, parent_key)
|
|
if mapping is not None:
|
|
items.append(mapping)
|
|
kinds.add("mapping")
|
|
i += 1
|
|
continue
|
|
|
|
entry = _block_mapping_entry(item)
|
|
if entry is not None:
|
|
mapping, i = _consume_block_mapping(fm_lines, i + 1, entry, parent_key)
|
|
items.append(mapping)
|
|
kinds.add("mapping")
|
|
continue
|
|
|
|
_reject_dangerous_value(item)
|
|
_reject_mapping_construct(item)
|
|
items.append(item)
|
|
kinds.add("scalar")
|
|
i += 1
|
|
|
|
if len(kinds) > 1:
|
|
raise OKFFrontmatterError(
|
|
"a block list may not mix scalar items and mappings: %r" % (parent_key,)
|
|
)
|
|
if not items:
|
|
return None, start
|
|
return items, i
|
|
|
|
|
|
def _block_mapping_entry(text):
|
|
"""Read ``text`` as one ``key: value`` block-mapping entry, or return ``None``.
|
|
|
|
The trigger is deliberately the same shape ``_reject_mapping_construct``
|
|
uses to *refuse* a scalar: an unquoted ``": "``. What changes in 1.3.0 is
|
|
only what happens next - the entry is admitted key-by-key against the
|
|
allowlist instead of refused wholesale. Every shape that is a scalar to
|
|
PyYAML stays one here: a quoted item, a colon with no space
|
|
(``domain:security``, ``https://e.com:8443/a``) and a trailing colon all
|
|
return ``None`` and fall through to the unchanged scalar rules.
|
|
"""
|
|
if not text or text[0] in _QUOTE_STARTS:
|
|
return None
|
|
key, sep, leaf = text.partition(": ")
|
|
if not sep:
|
|
return None
|
|
key = key.strip()
|
|
if not _KEY_RE.match(key):
|
|
return None
|
|
return key, leaf.strip()
|
|
|
|
|
|
def _consume_block_mapping(fm_lines, start, first_entry, parent_key):
|
|
"""Consume the sibling entries of a block mapping opened by a ``- `` item.
|
|
|
|
Returns ``(mapping, next_index)``. A sibling is an indented line that does
|
|
not open a new list item; the run ends at a blank line, a comment, a new
|
|
``- `` item, or a line at column zero. Depth is capped at one by giving the
|
|
leaves the *unchanged* scalar predicates: a nested collection opens with
|
|
``{`` or ``[`` and is refused by ``_reject_dangerous_value``, and a further
|
|
block level is refused by ``_reject_mapping_construct``.
|
|
"""
|
|
allowed = _allowed_mapping_keys(parent_key)
|
|
mapping = {}
|
|
_admit_mapping_entry(mapping, first_entry[0], first_entry[1], allowed, parent_key)
|
|
|
|
i = start
|
|
n = len(fm_lines)
|
|
while i < n:
|
|
raw = fm_lines[i]
|
|
stripped = raw.strip()
|
|
if stripped == "" or stripped.startswith("#"):
|
|
break
|
|
if raw[:1] not in (" ", "\t") or stripped.startswith("- "):
|
|
break
|
|
entry = _block_mapping_entry(stripped)
|
|
if entry is None:
|
|
_reject_dangerous_value(stripped)
|
|
_reject_mapping_construct(stripped)
|
|
raise OKFFrontmatterError(
|
|
"a block-mapping entry must be 'key: value': %r" % (raw,)
|
|
)
|
|
_admit_mapping_entry(mapping, entry[0], entry[1], allowed, parent_key)
|
|
i += 1
|
|
return mapping, i
|
|
|
|
|
|
def _admit_mapping_entry(mapping, key, leaf, allowed, parent_key):
|
|
"""Admit one mapping entry, or raise. The single gate both carriers pass."""
|
|
if not _KEY_RE.match(key):
|
|
raise OKFFrontmatterError("invalid mapping key: %r" % (key,))
|
|
if key not in allowed:
|
|
raise OKFFrontmatterError(
|
|
"mapping key %r is not on the OKF mapping allowlist under %r"
|
|
% (key, parent_key)
|
|
)
|
|
if key in mapping:
|
|
raise OKFFrontmatterError("duplicate mapping key %r" % (key,))
|
|
_reject_dangerous_value(leaf)
|
|
_reject_mapping_construct(leaf)
|
|
mapping[key] = leaf
|
|
|
|
|
|
def _reject_dangerous_value(value):
|
|
if value and value[0] in _DANGEROUS_VALUE_STARTS:
|
|
raise OKFFrontmatterError(
|
|
"value begins with a disallowed YAML indicator %r: %r"
|
|
% (value[0], value)
|
|
)
|
|
|
|
|
|
def _reject_mapping_construct(value):
|
|
"""Reject a scalar that YAML reads as a mapping rather than as a string.
|
|
|
|
T2 gives the mapping *class* exactly one expressible form, the typed
|
|
allowlisted flow mapping (G3); the nested-block and dotted-key routes still
|
|
raise, and this predicate is what keeps them raising — both at the top level
|
|
and on a leaf *inside* an admitted mapping. Two routes used to escape by degrading
|
|
into a string instead: a block-sequence item carrying exactly one key
|
|
(``- uri: x``), and an inline second colon (``attester: resource: x``).
|
|
Both parsed "successfully" into the wrong *type*, and a pointer parked in
|
|
one rode through in a key the ``resource`` allowlist never inspects.
|
|
|
|
``": "`` and a trailing ``":"`` are exactly the two shapes where a plain
|
|
scalar stops being one — ground-truthed against PyYAML 6.0.3, which reads
|
|
``- uri: x`` as ``[{'uri': 'x'}]``, ``- uri:`` as ``[{'uri': None}]``, and
|
|
refuses ``k: sub: v`` outright. A colon carrying neither a space nor a line
|
|
end opens no mapping (``domain:security``, ``https://e.com:8443/a``) and is
|
|
left alone, as is a quoted scalar — over-blocking a conformant bundle is
|
|
itself a failure mode.
|
|
"""
|
|
if not value or value[0] in _QUOTE_STARTS:
|
|
return
|
|
if ": " in value or value.endswith(":"):
|
|
raise OKFFrontmatterError(
|
|
"a mapping is not expressible in OKF frontmatter: %r" % (value,)
|
|
)
|
|
|
|
|
|
def _parse_flow_mapping(value, parent_key=None):
|
|
"""Parse ``{ key: value, ... }`` into a typed dict, or refuse it (G3).
|
|
|
|
Returns ``None`` when ``value`` does not open a flow mapping, so the caller
|
|
falls through to the unchanged scalar rules. Otherwise the value either
|
|
parses into a ``dict`` of allowlisted keys with plain-scalar leaves, or
|
|
raises - it never degrades into a string, which is the defect closed in
|
|
1.1.0 and not reopened here.
|
|
|
|
Why the mapping class needed *a* form at all: OKF v0.2 writes its whole
|
|
trust and provenance layer as mappings, and SPEC.md @ ``62432a09`` uses flow
|
|
form in its own examples (§5.1 ``usage_window``, §5.2 ``generated`` /
|
|
``verified``). §11 goes further than "should": a consumer *MUST* treat a
|
|
bare ``verified`` mapping as a one-element list - a rule that presupposes
|
|
the mapping parses. With no form, 0 of 53 upstream concepts reached the
|
|
gate, and no threshold would have changed that.
|
|
|
|
Why this form is safe: the allowlist inspects **every key**, which is the
|
|
property that actually carried the security in T2 - the blanket refusal was
|
|
the enforcement, not the point. Admitted, ground-truthed against PyYAML
|
|
6.0.3:
|
|
|
|
- one flow mapping per value, closed on the same line (``{ a: b }``);
|
|
- keys on :data:`_MAPPING_KEY_ALLOWLIST` and matching ``_KEY_RE``, no
|
|
duplicates - PyYAML resolves a duplicate last-wins, which is a way to
|
|
show one claim and mean another;
|
|
- plain-scalar leaves only, each run through the *unchanged*
|
|
``_reject_dangerous_value`` / ``_reject_mapping_construct`` predicates, so
|
|
a leaf can no more open an anchor, a tag or a nested mapping than a
|
|
top-level scalar can.
|
|
|
|
Refused, each on its own rule: nested collections (``{ a: { b: c } }``,
|
|
``{ a: [1] }``), quoted leaves, an empty mapping, an unclosed or
|
|
trailing-junk value (``{ a: b } x``, which PyYAML also refuses), a key
|
|
outside the allowlist, and ``{a:b}`` - which PyYAML reads as the *key*
|
|
``a:b``, not as a scalar, and which the required ``": "`` separator catches.
|
|
|
|
Two deliberate divergences from PyYAML, both toward refusal: a quoted leaf
|
|
(``{ title: 'a, b' }``) and a trailing comment (``{ a: b } # note``) are
|
|
conformant YAML that this rejects. Splitting quoted commas correctly needs a
|
|
quote state machine whose failure mode is *accepting* something YAML would
|
|
refuse; refusing is the cheaper side to be wrong on, and the keys that
|
|
plausibly need a comma (``title``, ``author``) only occur inside ``sources``
|
|
entries, whose block-sequence carrier is refused anyway.
|
|
"""
|
|
if not value or value[0] != "{":
|
|
return None
|
|
if not value.endswith("}"):
|
|
raise OKFFrontmatterError(
|
|
"a flow mapping must be closed by '}' on the same line: %r" % (value,)
|
|
)
|
|
|
|
inner = value[1:-1].strip()
|
|
if inner.endswith(","): # a trailing comma is legal YAML; one, and only one
|
|
inner = inner[:-1].strip()
|
|
if not inner:
|
|
raise OKFFrontmatterError("an empty flow mapping carries nothing: %r" % (value,))
|
|
for char in "{}[]":
|
|
if char in inner:
|
|
raise OKFFrontmatterError(
|
|
"a flow mapping admits scalar leaves only, not %r: %r" % (char, value)
|
|
)
|
|
for quote in _QUOTE_STARTS:
|
|
if quote in inner:
|
|
raise OKFFrontmatterError(
|
|
"a quoted scalar inside a flow mapping is not a supported form: %r"
|
|
% (value,)
|
|
)
|
|
|
|
allowed = _allowed_mapping_keys(parent_key)
|
|
mapping = {}
|
|
for entry in inner.split(","):
|
|
entry = entry.strip()
|
|
key, sep, leaf = entry.partition(": ")
|
|
if not sep:
|
|
raise OKFFrontmatterError(
|
|
"a flow-mapping entry must be 'key: value': %r" % (entry,)
|
|
)
|
|
_admit_mapping_entry(mapping, key.strip(), leaf.strip(), allowed, parent_key)
|
|
return mapping
|
|
|
|
|
|
def _parse_flow_sequence(value, parent_key=None):
|
|
"""Parse ``[{ ... }, { ... }]`` into a list of typed dicts, or refuse it (G30).
|
|
|
|
Returns ``None`` when ``value`` does not open a flow sequence, so the caller
|
|
falls through to the unchanged scalar rules - where ``[`` is still a
|
|
disallowed indicator. This carrier is opened for the flow-mapping element
|
|
and nothing else: it is the form the OKF producers emit for ``sources``
|
|
(measured 02.09 against llm-ingestion-okf's golden bundle, where a
|
|
one-element sequence raised on the ``[`` just as a two-element one did).
|
|
|
|
As of 1.4.0 it also carries a sequence of plain *scalars*
|
|
(``tags: [a, b, c]``) - SPEC.md §4.1's own skeleton for ``tags``, and the
|
|
one candidate form measured to move the upstream corpus at all (0/53 ->
|
|
6/53 against ``_okf-upstream`` @ 3fcbb9f, denominator 53; see
|
|
docs/2026-09-07-limitations-44-maaling.md). The quoting and comma-splitting
|
|
problem that kept it refused is answered by refusing the characters that
|
|
create it rather than by parsing them: an element is a plain scalar only if
|
|
it is non-empty and carries none of ``{ } [ ] : , " ' #``, and it then
|
|
passes the unchanged scalar indicator rule. Everything needing YAML
|
|
semantics to split or unquote correctly still raises.
|
|
|
|
A mapping element is split on ``}`` rather than on commas, which is sound
|
|
precisely because ``_parse_flow_mapping`` admits no nested collection: a
|
|
``}`` inside an element cannot occur, so the first ``}`` after ``{`` always
|
|
closes it. A scalar element runs to the next comma, which cannot occur
|
|
inside one. Anything between elements that is not a separating comma is
|
|
refused, which is what makes trailing junk fail rather than parse.
|
|
|
|
A sequence may not mix the two, for the reason the block list may not: a
|
|
consumer iterating the value and reading ``entry.get("id")`` gets an
|
|
``AttributeError`` off the first ``str``. The mixing verdict is reached
|
|
before the element is parsed, so the caller is told about the mix rather
|
|
than about a key the allowlist would have complained of instead.
|
|
"""
|
|
if not value or value[0] != "[":
|
|
return None
|
|
if not value.endswith("]"):
|
|
raise OKFFrontmatterError(
|
|
"a flow sequence must be closed by ']' on the same line: %r" % (value,)
|
|
)
|
|
|
|
inner = value[1:-1].strip()
|
|
if not inner:
|
|
raise OKFFrontmatterError("an empty flow sequence carries nothing: %r" % (value,))
|
|
|
|
items = []
|
|
kinds = set()
|
|
i = 0
|
|
n = len(inner)
|
|
while True:
|
|
while i < n and inner[i] in " \t":
|
|
i += 1
|
|
if i >= n:
|
|
break
|
|
if inner[i] == "{":
|
|
_refuse_mixed_flow_sequence(kinds, "mapping", value)
|
|
close = inner.find("}", i)
|
|
if close == -1:
|
|
raise OKFFrontmatterError(
|
|
"an unclosed flow mapping inside a flow sequence: %r" % (value,)
|
|
)
|
|
items.append(_parse_flow_mapping(inner[i:close + 1], parent_key))
|
|
i = close + 1
|
|
else:
|
|
_refuse_mixed_flow_sequence(kinds, "scalar", value)
|
|
end = i
|
|
while end < n and inner[end] != ",":
|
|
end += 1
|
|
items.append(_flow_sequence_scalar(inner[i:end].strip(), value))
|
|
i = end
|
|
while i < n and inner[i] in " \t":
|
|
i += 1
|
|
if i >= n:
|
|
break
|
|
if inner[i] != ",":
|
|
raise OKFFrontmatterError(
|
|
"trailing junk after a flow-sequence element: %r" % (value,)
|
|
)
|
|
i += 1
|
|
return items
|
|
|
|
|
|
def _refuse_mixed_flow_sequence(kinds, kind, value):
|
|
kinds.add(kind)
|
|
if len(kinds) > 1:
|
|
raise OKFFrontmatterError(
|
|
"a flow sequence may not mix scalar items and mappings: %r" % (value,)
|
|
)
|
|
|
|
|
|
def _flow_sequence_scalar(element, value):
|
|
"""Read one flow-sequence element as a plain scalar, or refuse it (P1).
|
|
|
|
Character-level, with no YAML semantics: the element must be non-empty and
|
|
carry none of :data:`_FLOW_SCALAR_REFUSED`. That set is not a style rule -
|
|
each member is a character whose meaning this parser would have to guess at.
|
|
A quote would have to be stripped (this parser retains quotes, so it would
|
|
hand back a different value than YAML reads); a comma or a colon would have
|
|
to be split on; a bracket or a brace would open a second collection level,
|
|
which no carrier here admits; a ``#`` opens a comment. The unchanged
|
|
indicator rule then applies to what is left, exactly as it does to a
|
|
block-list item, so an anchor or an alias is no more a scalar here.
|
|
"""
|
|
if not element:
|
|
raise OKFFrontmatterError(
|
|
"an empty element in a flow sequence carries nothing: %r" % (value,)
|
|
)
|
|
for char in _FLOW_SCALAR_REFUSED:
|
|
if char in element:
|
|
raise OKFFrontmatterError(
|
|
"a flow-sequence scalar admits plain scalars only, not %r: %r"
|
|
% (char, value)
|
|
)
|
|
_reject_dangerous_value(element)
|
|
return element
|