Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
07d440a
fix(forge): the seed guard kept a contract that dropped a seeded expose
fas89 Oct 13, 2026
c5bde4c
fix(odps): a malformed contractId aborted the import, and a probed fi…
fas89 Oct 13, 2026
20fc3bd
security(yaml): cap what aliases expand to, and parse ODCS/ODPS files…
fas89 Oct 13, 2026
9ef2417
fix(odps-bitol): validate reports a YAML file over the alias caps as …
fas89 Oct 13, 2026
08e48ad
fix(odps): the lenient skip warning printed a contractId's NUL byte raw
fas89 Oct 13, 2026
d5cbac9
refactor(odps): name the contractId check, and fold the new resolver …
fas89 Oct 13, 2026
ba589a8
security(yaml): read a stream only up to the cap, and make every call…
fas89 Oct 13, 2026
969e82a
fix(odcs,odps): the remaining ODCS readers use the YAML guard, and a …
fas89 Oct 13, 2026
1ee62b6
test(forge): prove the seed guard end to end through the repair loop
fas89 Oct 13, 2026
efad23c
docs(changelog): upgrade notes, a Security entry and exact counts for…
fas89 Oct 13, 2026
74904a9
fix(fanout,odcs,odps-bitol): a file that does not parse is reported, …
fas89 Oct 19, 2026
48c9575
refactor(odps): classify a bundle directory's files in one helper
fas89 Oct 19, 2026
df032ac
docs(changelog): the expansion cap is new for every YAML file, and em…
fas89 Oct 19, 2026
aed0a4a
security(yaml): cap the text aliases repeat and how deep a file nests
fas89 Oct 19, 2026
39d31b7
fix(fanout): `fluid generate artifacts` reports a contract it cannot …
fas89 Oct 19, 2026
53c9459
fix(odps,odcs): a bundle file that does not parse is reported, not dr…
fas89 Oct 19, 2026
223533b
test(forge): keep the seed runtime test off the network
fas89 Oct 19, 2026
62562c4
docs(changelog): the depth and text caps, and the round-3 fixes
fas89 Oct 19, 2026
c5a64e5
test(fanout): pin a non-UTF-8 contract through run_fanout, and a path…
fas89 Oct 19, 2026
871ed0c
docs(changelog): state the text cap in characters, and the report fix…
fas89 Oct 19, 2026
c0fc622
chore(odcs): drop the local json and Path imports the module already has
fas89 Oct 19, 2026
81a084d
security(credentials): list REKA_API_BASE, which litellm 1.106.0 reads
fas89 Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Upgrade notes

- The YAML guard has new caps for every file it reads, FLUID contracts included: a file
is refused when it nests more than 128 levels deep, or when its aliases add more than
100,000 nodes or 5,242,880 characters of text, or grow it more than 100-fold past 1,000
nodes. The 5 MiB size cap now counts UTF-8 bytes for text as well as for bytes. ODCS and
ODPS files now go through the guard at all, so `fluid odps import`/`validate`, `fluid
odcs validate`/`import`, `fluid odps-bitol validate`, the `contractId` resolver and
`fluid forge --seed-from` also refuse a YAML file over 5 MiB or with more than 50
aliases, the caps FLUID contracts already had. Write the repeated blocks out instead of
reusing them through anchors. A bundle directory without a readable ODPS doc that holds
a file the guard refuses, or one that does not parse, fails instead of importing as
ODCS-only.
- A file the resolver probes for a port's `contractId` must now declare that `contractId`
as its `id`. A bundle that relied on a file named after the port's `contractId` but
declaring another id fails a strict import, or skips the port under `--lenient` (an
input port is always skipped); make the port's `contractId` and the ODCS `id` agree.

### Fixed

- **`fluid forge --seed-from` catches a dropped expose.** When the model left out an
expose the seed declares, the ground-truth guard raised `IndexError`, logged
`structural_seed_guard_failed` and kept the generated contract, so the expose was lost
without an error. A missing expose is now a seed mismatch, which the repair loop feeds
back to the model on its next attempt.
- **A malformed or mismatched `contractId` no longer breaks an ODPS import.** A
`contractId` with a NUL byte, a value that is not a string, a segment longer than a file
name may be, or an unparsable URL such as `http://[::1` made `fluid odps import` fail
with `ValueError`, `TypeError` or `OSError`, even with `--lenient` and on input ports,
which always import leniently. It is now an unresolved contract: `--lenient` and input
ports skip it with a warning, and a strict import stops with an unresolved-contract
error (`not a valid contractId` for a NUL byte or a value that is not a string, `false`
and `0` included, which a strict import used to skip; an empty or missing one is
ignored, as before). A file the resolver probes
for a `contractId`, such as `orders.odcs.yaml` for `commerce.orders`, is now used only
when it declares that `id`; a file that declares another contract is skipped with a
warning naming both ids instead of being linked to the port.
- **`fluid odps import <dir>` no longer drops a product doc it cannot read.** A product
file with a YAML syntax error, or with an upper-case suffix such as `PRODUCT.ODPS.YAML`
(which the ODCS reader parsed as JSON), was skipped without a word, and the directory
imported as ODCS-only. Every ODCS/ODPS reader now reads an upper-case `.YAML`/`.YML`
suffix as YAML, and a directory file that does not parse is skipped with a warning
naming it; a directory left without a readable ODPS doc fails.
- **`fluid generate artifacts` reports a contract it cannot read.** With `--emit
schedule` alone, a contract that did not parse skipped the only emitter and exited 0
with no files; a contract that was not UTF-8 or not a mapping ended in "Unexpected
error". Each is now a `generate_artifacts_failed` error naming the file.
- **`fluid odcs validate --report` writes the report when the YAML does not parse.** The
command printed the failure and exited 1 without the report, so a CI gate reading it
found no file; the report now records a failed `parse` pass.

### Security

- **The YAML guard caps what aliases expand to, in nodes and in text, and how deep a file
nests.** It allowed 50 aliases, which is not a bound: 22 levels of `[*a, *a]` are 44
aliases that expand to 25,165,822 nodes, and a 685-byte contract shaped like that held
`fluid validate` for more than 90 seconds. A document is now also refused when its
aliases add more than 100,000 nodes or 5,242,880 characters of text (a
1,000,000-character string repeated through two nested lists of four aliases adds
20,000,000), or grow it more than 100-fold once it is past 1,000 nodes, and the error
names the sizes. A file nested more than 128 levels deep is refused before PyYAML scans
it to the end (a 64 KB file nested 32,000 deep took over 40 seconds) or fails with
`RecursionError`, and a stream is read only up to the 5 MiB cap. The ODCS and ODPS
readers had no cap at all: `fluid odps import`, the `contractId` resolver (fetched
contracts included), `fluid forge --seed-from`, `fluid odps-bitol validate` and `fluid
odcs validate`/`import` read YAML with a plain `yaml.safe_load`. They now use the same
guard and report a refused file as a failed validation or import, and the OpenAPI
validator, the artifact fan-out and the federation manifest reader report it as the
unreadable file it is instead of failing with `UnsafeYamlError`.
- **A committed `.env` can no longer point `REKA_API_BASE` at the FLUID LLM key.**
litellm 1.106.0 added Reka to its registry of OpenAI-compatible providers, read
from `REKA_API_BASE`, and the vendored list of litellm endpoints did not have it.
With `FLUID_LLM_API_KEY` (or the keyring key) in use, a project's `REKA_API_BASE`
is now ignored, as every other litellm provider endpoint is.

## [0.20.0] — 2026-10-15

### Security advisories
Expand Down
12 changes: 8 additions & 4 deletions fluid_build/cli/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,18 @@ def load_yaml_json(path: Path) -> Any:
``if .yaml then yaml.safe_load else json.load`` pattern that was
previously duplicated across ``cli/opds.py``, ``cli/odcs.py``, and
``forge/core/artifact_fanout.py``.

YAML goes through :func:`fluid_build.util.safe_yaml.load_yaml_safe`: the
files it reads (ODCS contracts for ``fluid odcs``) come from other teams,
so one over its caps raises ``UnsafeYamlError``.
"""
import json as _json

with open(path, encoding="utf-8") as f:
if path.suffix in (".yaml", ".yml"):
import yaml as _yaml
from fluid_build.util.safe_yaml import load_yaml_safe

return _yaml.safe_load(f)
with open(path, encoding="utf-8") as f:
if path.suffix.lower() in (".yaml", ".yml"):
return load_yaml_safe(f)
return _json.load(f)


Expand Down
13 changes: 11 additions & 2 deletions fluid_build/cli/forge_copilot_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,22 @@ def _ground_truth_paths_for(fluid: Mapping[str, Any]) -> List[str]:


def resolve_at_path(fluid: Mapping[str, Any], path: str) -> Any:
"""Look up the value at a dotted/indexed path like ``exposes[0].contract.schema``."""
"""Look up the value at a dotted/indexed path like ``exposes[0].contract.schema``.

Returns ``None`` for anything missing, including an index past the end of
a list or into a non-list. The candidate is LLM output of any shape, and a
lookup that raised made :func:`diff_against_seed` raise, which the runtime
logs as ``structural_seed_guard_failed`` and then keeps the contract.
"""
import re

cursor: Any = fluid
for token in re.findall(r"[^.\[\]]+|\[\d+\]", path):
if token.startswith("[") and token.endswith("]"):
cursor = cursor[int(token[1:-1])]
index = int(token[1:-1])
if not isinstance(cursor, (list, tuple)) or index >= len(cursor):
return None
cursor = cursor[index]
else:
if isinstance(cursor, Mapping):
cursor = cursor.get(token)
Expand Down
33 changes: 26 additions & 7 deletions fluid_build/cli/odcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,17 @@ def _run_odcs_import(args):
import json
from pathlib import Path

import yaml

from fluid_build.util.safe_yaml import UnsafeYamlError

# Import ODCS
provider = OdcsProvider()
fluid_contract = provider.import_contract(args.odcs_file)
try:
fluid_contract = provider.import_contract(args.odcs_file)
except (UnsafeYamlError, yaml.YAMLError) as exc:
console_error(f"Error importing: {exc}")
return 1

# Generate output path if not specified
if not args.output:
Expand Down Expand Up @@ -506,25 +514,33 @@ def _run_odcs_validate(args):
Exit code is 1 iff any pass surfaces a violation. With ``--report PATH``
every result is also serialised to a JSON file for CI gates.
"""
import json
from pathlib import Path

import yaml

from fluid_build.cli._common import load_yaml_json
from fluid_build.providers.odcs.validation import (
collect_errors,
load_schema,
roundtrip_check,
validate_via_vowl,
)
from fluid_build.util.safe_yaml import UnsafeYamlError

file_path = Path(args.odcs_file)
odcs_data = load_yaml_json(file_path)

report: dict = {
"file": str(file_path),
"passes": {},
"ok": True,
}
try:
odcs_data = load_yaml_json(file_path)
except (UnsafeYamlError, yaml.YAMLError) as exc:
cprint(f"✗ Validation failed: {exc}")
report["passes"]["parse"] = {"ok": False, "error": str(exc)[:300]}
report["ok"] = False
_write_validate_report(args, report)
return 1

# Pass 1 — jsonschema (multi-error)
schema = load_schema()
Expand Down Expand Up @@ -590,15 +606,18 @@ def _run_odcs_validate(args):
f"extra={len(rt['extra'])} changed={len(rt['changed'])}"
)

# Optional structured report (CI gate)
_write_validate_report(args, report)
return 0 if report["ok"] else 1


def _write_validate_report(args, report: dict) -> None:
"""Write the optional structured report (CI gate) for ``--report``."""
if getattr(args, "report", None):
report_path = Path(args.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
cprint(f"📄 Report: {report_path}")

return 0 if report["ok"] else 1


def _run_odcs_info(args):
"""Run ODCS info command."""
Expand Down
20 changes: 13 additions & 7 deletions fluid_build/cli/odps_standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,10 @@ def validate_command(odps_file: str):

file_path = Path(odps_file)
with open(file_path, encoding="utf-8") as f:
if file_path.suffix in (".yaml", ".yml"):
import yaml
if file_path.suffix.lower() in (".yaml", ".yml"):
from fluid_build.util.safe_yaml import load_yaml_safe

odps_data = yaml.safe_load(f)
odps_data = load_yaml_safe(f)
else:
odps_data = json.load(f)

Expand Down Expand Up @@ -319,12 +319,18 @@ def _run_odps_validate(args):
import json
from pathlib import Path

import yaml

from fluid_build.util.safe_yaml import UnsafeYamlError, load_yaml_safe

file_path = Path(args.odps_file)
with open(file_path, encoding="utf-8") as f:
if file_path.suffix in (".yaml", ".yml"):
import yaml

odps_data = yaml.safe_load(f)
if file_path.suffix.lower() in (".yaml", ".yml"):
try:
odps_data = load_yaml_safe(f)
except (UnsafeYamlError, yaml.YAMLError) as exc:
cprint(f"✗ Validation failed: {exc}")
return 1
else:
odps_data = json.load(f)

Expand Down
7 changes: 4 additions & 3 deletions fluid_build/credentials/litellm_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
project's own setting and follows the name-shape rule.

Vendored because importing litellm takes seconds and dotenv hydration runs on
every command. Generated from the litellm 1.83.14, 1.97.0 and 1.105.0 wheels
(FLUID's floor, the last release Python 3.10 can import, and the newest), as
the union of:
every command. Generated from the litellm 1.83.14, 1.97.0, 1.105.0 and
1.106.0 wheels (FLUID's floor, the last release Python 3.10 can import, and the
two newest), as the union of:

* ``<PROVIDER>_API_BASE`` for every ``litellm.provider_list`` entry (``-``
read as ``_``);
Expand Down Expand Up @@ -218,6 +218,7 @@
"RAGFLOW_API_BASE",
"RECRAFT_API_BASE",
"REDUCTO_API_BASE",
"REKA_API_BASE",
"REPLICATE_API_BASE",
"RUNWAYML_API_BASE",
"S3_VECTORS_API_BASE",
Expand Down
29 changes: 22 additions & 7 deletions fluid_build/forge/core/artifact_fanout.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
import yaml

from fluid_build.forge.core.bundle import _slug, build_manifest, validate_manifest
from fluid_build.util.safe_yaml import load_yaml_safe
from fluid_build.util.safe_yaml import UnsafeYamlError, load_yaml_safe

LOG = logging.getLogger("fluid.forge.core.artifact_fanout")

Expand Down Expand Up @@ -258,7 +258,9 @@ def _contract_is_reference_only(contract_path: Path) -> bool:
try:
with open(contract_path, "r", encoding="utf-8") as fh:
contract = load_yaml_safe(fh) or {}
except (OSError, yaml.YAMLError):
except (OSError, UnicodeDecodeError, yaml.YAMLError, UnsafeYamlError):
return False
if not isinstance(contract, dict):
return False
builds = contract.get("builds")
if not isinstance(builds, list):
Expand All @@ -277,8 +279,8 @@ def _load_schedule_contract(
Loaded through the same loader (overlay, ``$ref`` resolution and alias
normalisation included) so the gate below and the renderer agree on
which builds carry a trigger. ``None`` only when the contract file itself
cannot be read, which skips ``schedule`` exactly as it always has (the
other emitters then report the unreadable file). A contract that reads
cannot be read; ``run_fanout`` then reports the unreadable file, as every
other emitter does. A contract that reads
but will not load, a malformed overlay above all, raises: skipping would
drop the schedule while stage 3 reported success, and ``fluid apply
--env`` fails on the same overlay.
Expand All @@ -288,7 +290,7 @@ def _load_schedule_contract(
try:
with open(contract_path, "r", encoding="utf-8") as fh:
load_yaml_safe(fh)
except (OSError, yaml.YAMLError):
except (OSError, UnicodeDecodeError, yaml.YAMLError, UnsafeYamlError):
return None
try:
contract = load_contract_with_overlay(str(contract_path), overlay_env, logger)
Expand Down Expand Up @@ -459,8 +461,17 @@ def _slug_from_contract(contract: Dict[str, Any]) -> str:


def _load_contract(contract_path: Path) -> Dict[str, Any]:
with open(contract_path, "r", encoding="utf-8") as fh:
doc = load_yaml_safe(fh)
try:
with open(contract_path, "r", encoding="utf-8") as fh:
doc = load_yaml_safe(fh)
except OSError as exc:
# FanoutError is what `fluid generate artifacts` reports; anything else
# ends as an unhandled exception.
raise FanoutError(
f"contract at {contract_path} could not be read: {exc}", key=None
) from exc
except (UnicodeDecodeError, yaml.YAMLError, UnsafeYamlError) as exc:
raise FanoutError(f"contract at {contract_path} did not parse: {exc}", key=None) from exc
if not isinstance(doc, dict):
raise FanoutError(
f"contract at {contract_path} did not parse as a mapping",
Expand Down Expand Up @@ -888,6 +899,10 @@ def run_fanout(
if "schedule" in emits:
schedule_contract = _load_schedule_contract(resolved_contract, overlay_env, logger)
schedule_skip = _schedule_skip_reason(schedule_contract)
if schedule_skip == "generate_artifacts_skip_schedule_unreadable":
# Raises the FanoutError every other emitter would; with
# `--emit schedule` alone, skipping would report success.
_load_contract(resolved_contract)
if "schedule" in emits and schedule_skip is not None:
logger.info(schedule_skip, extra={"hint": _SCHEDULE_SKIP_HINTS[schedule_skip]})
emits = [k for k in emits if k != "schedule"]
Expand Down
4 changes: 2 additions & 2 deletions fluid_build/forge/core/artifact_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,8 @@ def _validate_against_schema(

# Parse YAML or JSON depending on extension.
# SECURITY (billion-laughs DoS): generated/vendored artifacts are
# untrusted content — route YAML through ``load_yaml_safe`` (50-alias +
# 5 MiB caps) instead of bare ``yaml.safe_load`` so a hostile anchor-
# untrusted content — route YAML through ``load_yaml_safe`` (size, alias
# and alias-expansion caps) instead of bare ``yaml.safe_load`` so a hostile anchor-
# expansion payload can't OOM the artifact validator.
from fluid_build.util.safe_yaml import UnsafeYamlError, load_yaml_safe

Expand Down
4 changes: 2 additions & 2 deletions fluid_build/forge/core/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,8 @@ def _validate_yaml_file(self, file_path: Path):
# validator before the cap ever ran. Stat FIRST and reject
# oversized files as a validation issue — mirrors the
# stat-before-read in ``forge/federation.py`` and reuses the same
# :data:`MAX_YAML_BYTES` ceiling. ``load_yaml_safe`` (50-alias +
# 5 MiB caps) remains the parse path (defence in depth). Falls
# :data:`MAX_YAML_BYTES` ceiling. ``load_yaml_safe`` (size, alias
# and alias-expansion caps) remains the parse path (defence in depth). Falls
# back to the bare loader only when PyYAML itself is unavailable.
try:
from fluid_build.util.safe_yaml import MAX_YAML_BYTES, load_yaml_safe
Expand Down
4 changes: 2 additions & 2 deletions fluid_build/forge/core/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
confine_ref,
iter_external_refs,
)
from fluid_build.util.safe_yaml import load_yaml_safe
from fluid_build.util.safe_yaml import UnsafeYamlError, load_yaml_safe

LOG = logging.getLogger("fluid.forge.core.validators")

Expand Down Expand Up @@ -307,7 +307,7 @@ def validate_openapi(
spec = json.loads(content.decode("utf-8"))
else:
spec = load_yaml_safe(content)
except (yaml.YAMLError, json.JSONDecodeError) as exc:
except (yaml.YAMLError, UnsafeYamlError, json.JSONDecodeError) as exc:
return [
ValidationIssue(
file=path,
Expand Down
2 changes: 1 addition & 1 deletion fluid_build/forge/federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ def load_federation_manifest(workspace_root: Path) -> FederationManifest:
try:
with path.open(encoding="utf-8") as f:
doc = load_yaml_safe(f) or {}
except (OSError, yaml.YAMLError) as exc:
except (OSError, yaml.YAMLError, UnsafeYamlError) as exc:
LOG.warning("federation_manifest_unreadable: path=%s error=%s", path, exc)
return FederationManifest()
rows = doc.get("workspaces", [])
Expand Down
Loading
Loading