Skip to content

fix: untrusted-input guards for the seed guard, contractId resolver and YAML loader - #725

Merged
fas89 merged 22 commits into
mainfrom
fix/untrusted-input-guards
Oct 19, 2026
Merged

fas89 merged 22 commits into
mainfrom
fix/untrusted-input-guards

Conversation

@fas89

@fas89 fas89 commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

What and why

Three small bugs found while comparing forge against the Bitol ODCS/ODPS examples, plus the untrusted-input holes the reviews found around them.

  1. The seed guard failed open (fluid forge --seed-from). When the model dropped an expose that the seed declares, resolve_at_path raised IndexError. generate_copilot_artifacts logged structural_seed_guard_failed and kept the contract, so the expose was lost without an error. An index past the end of a list, or into a value that is not a list, now counts as a missing value. The guard reports a mismatch and the repair loop asks the model to restore the expose.
  2. The contractId resolver crashed on a NUL byte, a non-string id, an unparsable URL (http://[::1) or a path segment too long for a file name. It crashed even under --lenient and on input ports. It also linked a probed file that declared a different id.
    • A malformed id is now ContractNotFound, which lenient imports skip.
    • A probed file is used only when its id matches.
    • A falsy non-string id (false, 0) is no longer skipped silently under a strict import.
  3. load_yaml_safe capped the alias count but not what aliases expand to. 22 levels of [*a, *a] are 44 aliases that expand to 25,165,822 nodes. A 685-byte contract shaped like that held fluid validate for over 90 s. The ODCS/ODPS readers bypassed the guard entirely.
    • A document with aliases is now composed and walked once (iterative, memoised) before construction.
    • It is refused when its aliases add more than 100,000 nodes or 5,242,880 characters of text, or grow it 100-fold past 1,000 nodes.
    • The event pre-scan also refuses nesting past 128 levels. PyYAML's scanner is slow on deep flow nesting, and its recursive composer raises RecursionError a few hundred levels down.
    • Streams are read only up to the cap, looping past short reads. The byte cap counts UTF-8 bytes for text too.
    • Every ODCS/ODPS reader now goes through the guard: odcs/io.read_input, the resolver's fetch, fluid odcs validate/import, fluid odps-bitol validate, cli/_common.load_yaml_json. A refused or malformed file is reported as a failed validation or import, not as an unhandled exception.

The reviews turned up more failures on the same paths, all fixed here:

  • fluid generate artifacts --emit schedule exited 0 on a contract that did not parse. A non-UTF-8 or non-mapping contract ended in "Unexpected error".
  • fluid odps import <dir> silently dropped a product doc it could not parse, or one with an upper-case .YAML suffix (that file was parsed as JSON).
  • fluid odcs validate --report wrote no report when the YAML did not parse.

Recursive aliases are deliberately not refused by the guard. api/contract.py documents a self-referencing document as contract_load_failed. The module docstring now says which consumers refuse one and which do not.

CHANGELOG [Unreleased] has Upgrade notes covering:

  • the new caps apply to FLUID contracts too;
  • probed files must declare the contractId;
  • a bundle directory without a readable product fails.

Also in this PR, unrelated to the fixes above: CI installs the newest litellm, and 1.106.0 reads REKA_API_BASE, which the vendored LITELLM_ENDPOINT_NAMES did not list. test_the_vendored_names_cover_the_installed_litellm failed on every leg, and main would fail the same way. 81a084d lists the name and adds a Security entry: a committed .env could have pointed that endpoint at the FLUID LLM key.

Borrowed, not built

  • Expansion budget: follows OmegaConf 2.4's YAML alias limits (omegaconf/_yaml.py, BSD-3-Clause), with two changes:
    • the budget is the nodes aliases add, so a large alias-free contract still loads;
    • recursion is counted once, not refused.
  • Parser: PyYAML issue security: run Dockerfile.verify as non-root (trivy DS-0002) #235. No drop-in parser bounds expansion without dropping anchors. StrictYAML drops them, so it was rejected.
  • Depth cap 128: serde_json's default recursion limit. The deepest YAML file in this repo nests 9 levels, and all 219 repo YAML files load identically under the guard and yaml.safe_load.
  • Path probes skip a candidate that raises ValueError: the same pattern as util/ref_confinement.

Validation

  • Full suite (-n auto --dist loadscope): 26,572 passed, 4 failed.

    • The 4 failures are environmental and reproduce on unmodified main: dbt not installed (test_dotenv_unfiltered_readers), two duckdb temp-root tests, and test_coding_agent_live.
    • tests/perf: 38 passed, 7 xfailed.
  • Lint and format:

    • whole-tree ruff check fluid_build/ tests/ and black==24.10.0 --check;
    • the dead-code gate;
    • check_license_headers.py and lint-imports.
  • Red, then green: every new test from the last fix round fails on the pre-fix commit and passes here. The one exception is the bytes case of the UTF-8 size test, a regression guard.

  • Mutation: 18 mutants of this round's fixes; 14 caught, every one of the planned ones among them. Two of the four survivors are now pinned: a non-UTF-8 contract through run_fanout, and a path _load_contract cannot open. The other two are a suffix fix in a Click group that fluid does not wire in, and an input-port change whose only effect is a warning.

  • Live through the fluid CLI. Each was reproduced on the pre-fix code and checked on this branch:

    Input Command Before After
    Scalar bomb fluid validate — refused in 2.9 s
    32,000-deep file fluid odcs validate over 20 s refused in 2 s
    Malformed contract fluid generate artifacts --emit schedule exit 0 exit 1 generate_artifacts_failed
    Latin-1 contract fluid generate artifacts — exit 1 generate_artifacts_failed
    Malformed YAML fluid odcs validate --report no report report written
    Bundle with a malformed product fluid odps import <dir> imported as ODCS-only fails
    PRODUCT.ODPS.YAML bundle fluid odps import <dir> — imports as a product
    300-character contractId fluid odps import --lenient aborted skipped with a warning
    300-character contractId fluid odps import — fails

    fluid odcs import on a 400 KB text-expansion file ran past 20 s before (an earlier measurement: 143 s, 230 MB written). It is refused in 0.7 s.

  • ripwire --quality-delta, measured against a no-op control on main to drop environmental rows. Accepted:

    • load_yaml_safe takes 7 parameters, one keyword-only cap each, mirroring max_bytes/max_aliases;
    • ContractResolver class LOC 267→297 (class size is a tracked follow-up);
    • TestRunFanout LOC;
    • short pytest.raises tests matching unrelated tests of the same shape.

    The complexity growth in resolve() and load_yaml_safe was fixed by extracting _probe_local, _bounded_text and _count_aliases.

Review verdicts

  • security-review (every round, the last over the final fix round): no HIGH or MEDIUM. Two LOW notes are left as follow-ups:
    • the bundle skip warning, and the --report parse error, carry PyYAML's ~40-character excerpt of the bad line (stdout already shows it);
    • a symlinked bundle entry is read like any other file, as before.
  • world-class inspection: architecture PASS and docs PASS. The test suite was re-reviewed after the final round: PASS. The flagged non-hermetic seed test now makes 0 network calls (1.2 s, was 10–12 s).
  • defect-scan: 4 parallel hunters, then a skeptic and referee pass. All REAL findings in touched code are fixed and were re-verified before and after through the CLI: A–I, L, M, O.
    • J and K were refuted (NOT_A_BUG).
    • N (argparse handlers exiting 2 on non-UTF-8, missing or empty files, including malformed JSON for odcs validate --report) was pre-existing and is left as a follow-up.
    • Three LOW pre-existing findings from the re-verify are follow-ups:
      • depth reached through chained aliases is not capped;
      • an empty contract passes --emit schedule alone;
      • odcs validate still exits 2 on malformed JSON.

Documentation

Follow-ups (not in this PR)

  • Route the 21 remaining raw yaml.safe_load sites on untrusted input through the guard, and add a semgrep rule.
  • Make UnsafeYamlError subclass yaml.YAMLError.
  • Parse errors read <unicode string> instead of the file name.
  • The copilot LLM-output parse lets UnsafeYamlError escape.
  • JSON inputs are uncapped.
  • Cap expanded depth in the alias walk.
  • Log the type and position of a parse error, not PyYAML's excerpt.
  • The resolver's remote fetch quotes a !!float/!!int body value in a raw ValueError. This is already on main.

fas89 added 20 commits October 19, 2026 06:18
resolve_at_path indexed the candidate with cursor[i], so a candidate
with fewer exposes than the seed raised IndexError, a dict or number in
a list's place raised KeyError or TypeError, and a string returned one
of its characters. diff_against_seed raised with it, and
generate_copilot_artifacts catches every guard exception, logs
structural_seed_guard_failed and keeps the candidate: the guard failed
open on the mutation it exists to catch.

An index past the end of a list, or into anything that is not a list,
is now a missing value, so a dropped expose is a mismatch and the
repair loop asks the model to restore it.
…le with another id was linked

contractId is document text, and the resolver probed paths with it
unchecked. A NUL byte raised ValueError from Path.resolve (only OSError
and RuntimeError were caught), a non-string raised TypeError, and an
unparsable URL such as "http://[::1" raised ValueError from urlparse.
BitolOdpsProvider._safe_resolve catches only ContractNotFound, so
`fluid opds import` failed even with --lenient and on input ports,
which always resolve leniently.

A contractId that is not a non-empty string without NUL bytes now
raises ContractNotFound with the reason, which lenient imports skip;
_looks_like_url treats an unparsable URL as not a URL, and the path
probe also skips a candidate that raises ValueError, as
util/ref_confinement does.

The last-segment probe tries orders.odcs.yaml for "commerce.orders" and
accepted whatever contract that file held. A probed file is now used
only when it declares the contractId as its id, as ODPS ports reference
ODCS ids; a mismatch is skipped with a warning naming both ids, so a
later candidate can still match. Index lookups match by id already and
explicit hints are unchanged.
… with the guard

load_yaml_safe counted alias events (at most 50) on the theory that a
billion-laughs payload needs more. It does not: a list of two aliases
to the level below doubles per level, so 22 levels are 44 aliases and
2**22 nodes. Composition shares the anchored node, so the load itself
is fast, but every later walk sees the expanded tree: a 685-byte
contract shaped like that held `fluid validate` for more than 90 s.

A document that has aliases is now composed and walked once,
iteratively, sizing each node's expanded tree with memoisation, before
it is constructed. It is refused when its aliases add more than
MAX_YAML_ALIAS_EXPANSION (100,000) nodes, or grow it more than 100-fold
past 1,000 nodes. The design follows OmegaConf 2.4's YAML alias limits
(omegaconf/_yaml.py, BSD-3-Clause) with two differences: the budget is
the nodes aliases add, not the total, so a large contract without
aliases still loads; and a recursive alias counts once instead of being
refused, so the contract API keeps reporting a document that contains
itself as its documented contract_load_failed. A document without
aliases skips the walk and loads exactly as before.

The ODCS reader (odcs.io.read_input, behind the Bitol import, the
contractId resolver and --seed-from), the resolver's fetched-body
parse and both `fluid odps-bitol validate` paths used a plain
yaml.safe_load, so none of them had even the count cap. They now go
through load_yaml_safe.
…a failed validation

`fluid odps-bitol validate` runs _run_odps_validate, which had no
handler around the load. With the file now read through load_yaml_safe,
an over-cap document ended as "CLI unhandled exception" (exit 2). It
now prints "Validation failed" with the reason and returns 1, as the
click command and every other validation failure in it do.
A malformed contractId now reaches the lenient skip in _safe_resolve
instead of aborting the import, and the warning formatted it with %s,
so a NUL byte (or any control character the document carries) went to
the terminal as is. Format it with %r, as ContractNotFound already
does for the same id.
…and import tests

The resolve() guard reads as one named predicate,
_is_valid_contract_id, instead of three inline conditions. The
malformed-id test now checks each case's reason in one parametrized
test, the fetched-bomb test leaves TestRemoteResolution for a
module-level test, and the two malformed-port import tests are one
parametrized test. No behaviour change.
…er report a refused file

load_yaml_safe read a stream whole before measuring it against the
5 MiB cap, so a huge file was buffered just to be refused; the readers
this branch routes through it pass open files. It now reads one unit
past the cap. A bytes input is measured before it is decoded.

The OpenAPI validator, the artifact fan-out (reference-only check and
schedule contract) and the federation manifest reader caught
yaml.YAMLError but not UnsafeYamlError. The expansion budget refuses
inputs the 50-alias count never did, so each one now crashed where it
reports bad YAML (OAS-PARSE, an unreadable contract, an empty
manifest); they catch UnsafeYamlError too, as upstream_discovery and
artifact_validators already did.

Tests pin what the audit found unpinned: the safe constructor on the
composed alias path (a Loader mutant ran !!python/object/apply), the
ratio rule on its own (13 levels, under the node budget), the 100,000
default (39 vs 45 aliases to a 2,501-node block), the budget counting
added nodes rather than document size, and the bounded read.
…refused file is reported

`fluid odcs validate` read through cli/_common.load_yaml_json, a plain
yaml.safe_load, so it still loaded an expansion bomb the Bitol readers
now refuse. load_yaml_json uses load_yaml_safe, and both `fluid odcs`
argparse paths report a refused file (validate: "Validation failed",
import: "Error importing", exit 1) instead of an unhandled exception.

import_directory classified files through _sniff_kind, which swallowed
every error: a product file over the caps was skipped and the rest of
the directory imported as ODCS-only with no error. A refused file is
now skipped with a warning naming it, and a directory with no readable
ODPS doc fails when the caps refused a file that may be it; an
unrelated refused file next to a readable product still imports.

A fetched body over the caps now names the reason (sizes and counts
only, never body text), the probe-mismatch warning prints the path with
%r, and the read_input and resolver docstrings state the caps and the
probe id rule.
generate_copilot_artifacts with a scripted model: attempt 1 drops a
seeded expose, attempt 2 returns the seed. On origin/main attempt 1 is
kept after "structural_seed_guard_failed ... list index out of range";
now it carries "Seed ground-truth violation at exposes[1]..." and the
final contract keeps every seeded expose.
… the guard fixes

The ODCS/ODPS readers now refuse files the caps refuse and a probed
sibling must declare the contractId, so both get an Upgrade note. The
YAML entry moves to Security, quotes the count the guard computes
(25,165,822 nodes, not 2^22), names `fluid odps` instead of the
deprecated `fluid opds`, and lists every reader and caller it covers;
the contractId entry quotes "not a valid contractId" only where it
applies, and the seed entry no longer promises a "restore" prompt.
…not an unhandled exception

`fluid generate artifacts` loads the contract through
artifact_fanout._load_contract in every emitter, and only a FanoutError
reaches its generate_artifacts_failed report. A contract the YAML guard
refuses, or one that is not YAML, escaped as UnsafeYamlError or
yaml.YAMLError; it is now a FanoutError naming the file. The two
pre-checks already treated it as unreadable.

`fluid odcs validate` and `fluid odps-bitol validate` reported a file
over the caps as a failed validation but still ended in "CLI unhandled
exception" on malformed YAML; both now report that as a failed
validation too, as `fluid odcs import` does.

The ratio-rule test pins the sizes the guard computes (30 to 49,150
nodes) instead of a bare raises, and a test docstring counts leaves and
aliases the way the CHANGELOG does.
import_directory now calls _classify_bundle_files, which sorts the
directory into ODPS docs, ODCS contracts and files the YAML caps
refused, and keeps only the policy (fail when nothing readable is the
product). The end-to-end seed test and the refused-file tests move to
their own classes. No behaviour change.
…pty contractIds are ignored

The Upgrade note said the ODCS/ODPS readers got the caps FLUID
contracts already had, but the 100,000-node expansion cap is new for
FLUID contracts too; it now says so and lists `fluid odps validate`.
The contractId entry no longer claims an empty id fails a strict
import: the provider skips a falsy contractId, as before.
The expansion budget counted nodes, so one anchored 1 MB string repeated
through two nested lists of four aliases added 20 nodes and 20 MB of
text; a 400 KB .odcs.yaml shaped like that made `fluid odcs import` run
143 s and write 230 MB. The walk now sizes each node's expanded scalar
text too, and refuses a document whose aliases add more than
MAX_YAML_ALIAS_EXPANSION_CHARS (the 5 MiB input cap again).

The pre-scan refuses nesting past MAX_YAML_DEPTH = 128 (serde_json's
default recursion limit; the deepest YAML file in the repo nests 9).
PyYAML's scanner slows with flow depth, so a 64 KB file nested 32,000
deep took over 40 s in the readers this branch routes through the
guard, and its recursive composer fails with RecursionError a few
hundred levels down, sooner under a deep caller stack.

A stream is read past short reads up to the cap (a raw stream can
return fewer units than asked before its end), and the size cap counts
UTF-8 bytes for text as it already did for bytes. The module docstring
no longer claims every consumer refuses a recursive alias: the FLUID
loader and the contract API do; the ODCS/ODPS readers do not.

load_yaml_safe keeps only the flow; reading and measuring the source
and the event pre-scan are their own helpers.
…read

The schedule emitter skips a contract it cannot read, leaving the other
emitters to report it. With `--emit schedule` alone there are none, so
a contract that did not parse exited 0 with no files. run_fanout now
loads such a contract through _load_contract, which raises the
FanoutError every other emitter would.

A contract that was not UTF-8 escaped every except tuple as
UnicodeDecodeError, and a list or scalar contract raised AttributeError
in the reference-only check; both ended in "Unexpected error". The
check now returns False for either, and _load_contract reports an
unreadable or undecodable file as a FanoutError naming it.
…opped

import_directory skipped a product doc with a YAML syntax error without
a word and imported the directory as ODCS-only; only a file the YAML
caps refused was reported. Any file that does not parse is now skipped
with a warning naming it, and a directory left without a readable ODPS
doc fails. _sniff_kind is folded into _classify_bundle_files.

The ODCS reader, load_yaml_json and both `fluid odps-bitol validate`
readers tested the suffix case-sensitively, so PRODUCT.ODPS.YAML was
parsed as JSON and never reached the caps. They lower-case it.

`if not cid` skipped a falsy non-string contractId (false, 0, []), so a
strict import succeeded with the port unresolved while a truthy one
failed as not a valid contractId; only None and "" are skipped now. A
contractId segment longer than a file name may be made the probe's
exists() raise OSError, which aborted even a lenient import; the probe
skips that candidate. The probe loop moves out of resolve() into
_probe_local.

`fluid odcs validate --report` returned before writing the report when
the file did not parse, so a CI gate reading it found nothing; it now
records a failed parse pass.
The end-to-end seed-guard test patches call_llm, but streaming and the
judge pass call the provider directly, so the test made DNS and HTTP
requests to api.openai.com, raw.githubusercontent.com and
localhost:11434 and took 10-12 s. It sets FLUID_LLM_STREAMING=0 and
FLUID_COPILOT_JUDGE=0; with sockets blocked it makes no network
attempt and runs in 1.5 s.
The Security entry no longer claims the guard caps what aliases expand
to while it counted nodes only: it now names the text cap and the depth
cap. The Upgrade note lists both and the UTF-8 byte count, and says an
unparsable bundle file also fails a directory without a product. The
contractId entry covers the long-segment OSError and the falsy
non-string ids a strict import used to skip, and three Fixed entries
cover the bundle directory, `fluid generate artifacts` and
`fluid odcs validate --report`.
… that will not open

Dropping UnicodeDecodeError from _load_schedule_contract, or the OSError
branch from _load_contract, left every test green while run_fanout on a
latin-1 contract, or _load_contract on a directory, crashed with the
raw exception. The run_fanout test now covers malformed and latin-1
contracts, with `--emit schedule` alone and the default set, and a
directory passed to _load_contract is a FanoutError.
… for YAML only

The text cap counts scalar characters (5,242,880), not bytes, so the
entries say so instead of "5 MiB of text". `fluid odcs validate
--report` writes the report when the YAML does not parse; a malformed
JSON or non-UTF-8 file still exits 2 without one, so the entry no longer
says "the file".
@github-actions github-actions Bot added provider Changes or requests related to providers cli Changes to the CLI surface or implementation tests Test coverage or test infrastructure changes needs-docs Pull request needs a linked docs update or justification labels Oct 19, 2026
@github-actions

Copy link
Copy Markdown

📄 Documentation Reminder

This PR appears to be missing a documentation reference. Our docs live in a separate repo.

Please update the PR description with one of:

  • Link a docs PR — check the "Docs PR linked" box and paste the URL
  • Mark as no docs needed — check "No docs needed" with a justification
  • Acknowledge docs TODO — check "Docs TODO" and create the docs PR before merge

See the Contributing Guide for details.

Comment thread fluid_build/cli/odcs.py Fixed
Comment thread tests/test_odcs.py Fixed
The --report helper re-imported json and Path, and the validate test
re-imported json; both are imported at module level (code-quality
review on #725).
@github-actions github-actions Bot removed the needs-docs Pull request needs a linked docs update or justification label Oct 19, 2026
litellm 1.106.0 adds Reka to llms/openai_like/providers.json with
api_base_env REKA_API_BASE. The vendored LITELLM_ENDPOINT_NAMES did not
have it, so test_the_vendored_names_cover_the_installed_litellm failed
on every CI leg, and a project .env could set the endpoint FLUID's LLM
key is sent to. It is listed now, and the docstring names 1.106.0 among
the wheels the list covers.
@github-actions github-actions Bot added the security Security-related changes or reports label Oct 19, 2026
@fas89
fas89 merged commit 20252d6 into main Oct 19, 2026
38 checks passed
@fas89
fas89 deleted the fix/untrusted-input-guards branch October 19, 2026 08:48
@fas89 fas89 mentioned this pull request Oct 19, 2026
1 of 3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Changes to the CLI surface or implementation provider Changes or requests related to providers security Security-related changes or reports tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant