fix(loader): confine $ref to the contract's directory tree - #687
Merged
Merged
Conversation
A relative $ref was joined to the contract's directory with '../'
honoured, and the only guards were "not absolute" and the system-
directory deny list. So a contract could compose any dict-rooted
YAML/JSON file the process can read into itself, and `fluid bundle`
printed it back (`fluid validate` passed). The Command Center runs
these commands on contracts its users upload.
Every external $ref now goes through one check,
fluid_build/util/ref_confinement.py::confine_ref:
- a URL scheme (file:// included) or //host form is treated as
remote and refused; nothing in FLUID fetches remote refs
- an absolute path (POSIX, Windows drive, UNC, rooted) is refused
- the target is Path.resolve()-d ('..' and symlinks) and must be
is_relative_to(root), root resolved the same way
- the root is the root contract's directory; nested refs are held to
that same root, not to the fragment's directory
- the check runs before the target is tested for existence, so a
refused ref is not a file-existence probe
The error is RefConfinementError, a RefResolutionError subclass (so
existing handlers catch it), naming the ref, the JSON pointer of the
$ref node and the file it is in, with .ref/.pointer/.source/.root.
Widening the root is an explicit opt-in: ref_root= on load_contract,
load_with_overlay and compile_contract, or FLUID_REF_ROOT for the CLI.
It must be an existing directory containing the contract, a blank
value means the default, and it is consulted only when the contract
has an external ref. URLs stay refused and the SecurePathValidator
deny list stays as a second layer under a widened root.
The bundle validator hands sources/openapi/* to openapi-spec-validator,
whose default handlers follow file:// (the target's values surface in
the error) and http(s)://. A bundled fragment has no directory, so the
same check with no root allows only '#/...' refs; anything else is an
OAS-REF-EXTERNAL error and the validator is not run on that fragment.
Same-document '#/...' refs are unchanged. Four tests that pinned the
old '../sibling' behaviour now go through the opt-in.
Borrowed, not built: datamodel-code-generator >= 0.62.0's containment
fix for CVE-2026-55389 (resolve, then is_relative_to(base_path);
file:// treated as remote; widening is an explicit opt-in) and
python-jsonschema/referencing's prefix-checked filesystem retriever
raising NoSuchResource. Neither resolver composes YAML fragments with
a JSON-pointer suffix, so the policy is borrowed and the resolver kept.
Docs: docs/contract-refs.md; SECURITY.md and AGENTS.md invariants.
FLUID_REF_ROOT is process-wide. Set once in a shell or a service
container, it applies to every contract the process loads. When a
contract with a file $ref was outside it (or the value was not a
directory), the load raised "contract ... is outside FLUID_REF_ROOT".
So `FLUID_REF_ROOT=<other dir> fluid validate
examples/0.7.1/bitcoin-multifile/contract.fluid.yaml` failed, and a
platform that sets it once would fail every uploaded contract with a
$ref, because each upload is materialised in a fresh temp directory.
docs/contract-refs.md said the variable did not affect other
contracts. The one test only covered ref-free contracts.
_effective_ref_root now handles the two sources differently:
- FLUID_REF_ROOT that is not a directory or does not contain the
contract is ignored for that contract. It logs a WARNING
(event ref_root_env_ignored, once per contract directory and value)
and uses the default root. That is the root the contract gets with
the variable unset, so nothing is widened, and refs that leave the
contract's directory still fail as escapes.
- ref_root= is set by the caller for one contract and stays strict:
RefResolutionError, naming ref_root.
The default confinement also breaks monorepos that shared fragments
with `$ref: ../other-product/...`. The bitcoin-multifile README
advertised that reuse. The README row and a new section now give the
FLUID_REF_ROOT / ref_root= step, checked against the CLI with a sibling
product. docs/contract-refs.md gains an upgrade note.
OAS-REF-EXTERNAL keeps rejecting a $ref key anywhere in a bundled
OpenAPI fragment, including example and x-* payloads that
openapi-spec-validator 0.9.0 does not follow. These keys cannot be
skipped by name. `properties: {example: {$ref: ...}}` is a schema
called "example", and the validator does follow its $ref (checked with
an audit hook on open()). This is now documented, and tests pin both
the data and the schema positions.
…nored When FLUID_REF_ROOT is not a directory, or does not contain the contract, the loader ignores it for that contract and logs ref_root_env_ignored. That warning is deduplicated per process (per contract directory and value). The RefConfinementError that follows still ended with the generic hint "set FLUID_REF_ROOT to that directory", which is wrong advice when the variable is already set. A second load in the same process (a Command Center worker, any library caller) got that wrong advice with no warning at all, and in a service the warning reaches only the server log, never the caller. _effective_ref_root now returns a _RefRoot (ref_root, root_hint, ignored_ref_root_env), and _resolve_refs threads both new fields through nested refs to confine_ref. When the fallback is taken, every escape error says "FLUID_REF_ROOT is set but was ignored for this contract: <reason>". This does not depend on the warning dedupe. RefConfinementError gains ignored_ref_root_env (None unless the variable was ignored), so a library caller can check it without parsing the message. docs/contract-refs.md said a FLUID_REF_ROOT left in the shell "never widens or breaks another contract". That holds only for contracts outside it. Every contract inside it is widened, silently. The docs now say so, and the upgrade note scopes the variable to a single command instead of exporting it. The new attribute is documented. Tests load twice through load_contract, compile_contract and load_with_overlay, for both a missing and a non-containing root, with the escape in a nested fragment. They check that both errors explain the fallback and that only the first load logs. These tests fail on the previous commit and also against a mutant that explains only on the first load. A separate test pins the generic hint and ignored_ref_root_env=None when the variable is unset or applied.
The fallback for an unusable FLUID_REF_ROOT only covered a value that resolved to something that is not a directory, or does not contain the contract. A value that is more broken raised before the fallback was reached: `~gone/repo` for a user that no longer exists (RuntimeError), a symlink loop (RuntimeError on 3.10/3.12), or a root under a directory the process cannot enter (PermissionError from is_dir). The raw error failed every contract with a file $ref, gave no ref_root_env_ignored warning, never named FLUID_REF_ROOT, and slipped past `except RefResolutionError`. ref_root= raised the same raw errors instead of the documented RefResolutionError. Resolution and the is_dir/containment checks now run under `except (OSError, RuntimeError, ValueError)`; a failure becomes "<origin>=<value> cannot be resolved: <reason>" and takes the existing path: RefResolutionError for ref_root=, ignored with the warning and the per-error hint for FLUID_REF_ROOT. The ref_root_env_ignored dedupe is now keyed on the value alone, not (contract directory, value). A service that loads each upload from a fresh temp directory added one permanent entry and logged one warning per upload; it now logs once per value and holds one key. Each later escape error still says the variable was ignored and why.
📄 Documentation ReminderThis PR appears to be missing a documentation reference. Our docs live in a separate repo. Please update the PR description with one of:
See the Contributing Guide for details. |
This was referenced Oct 2, 2026
…ow AGENTS.md's moved line detect-secrets in Lint & Format flagged SECRET = "SUPER-SECRET-VALUE" in the confinement tests. It is the sentinel the tests assert never reaches the output, not a credential, so it carries the repo's inline "pragma: allowlist secret". The hook also moved one known AGENTS.md entry in .secrets.baseline by a line, because this branch adds a line above it; committing that is what the hook asks for.
…ved" on Python 3.13+ too Python 3.13 stopped raising RuntimeError from a non-strict Path.resolve() on a symlink loop; it returns the path instead. On 3.13 and 3.14 a looping ref_root therefore fell through to "is not a directory", and the symlink_loop case of test_ref_root_argument_that_cannot_be_resolved_is_a_ref_resolution_error failed in CI (3.10 and 3.12 passed). When the non-strict resolution is not a directory, the root is now resolved again strictly: a loop or an untraversable parent raises into the existing "cannot be resolved" path on every version, and a missing directory keeps "is not a directory". Verified on 3.10, 3.12, 3.13 and 3.14; the 3.13 test fails without the change.
…inside a fixture test_env_root_containing_the_contract_still_widens_without_warning sat after the return of the module-level unresolvable_root fixture, which had been added between the class's methods, so pytest never collected it (reported by GitHub code quality as unreachable code). The fixture now sits above the class; the test is collected again and passes on 3.10, 3.12 and 3.13.
The rationale named one downstream service as the system these reads were found in. The engine's docs and docstrings now describe the case generically: services, CI jobs and shared hosts that run contracts other people wrote.
… through GitHub code quality flagged the FileNotFoundError 'pass' added for the Python 3.13 symlink-loop fix as an empty except without an explanation.
fas89
added a commit
that referenced
this pull request
Oct 2, 2026
With #687 on main, a $ref holding a NUL byte is refused by the ref confinement check as RefConfinementError, a RefResolutionError, so both the file and the in-memory forms report contract_ref_unresolved instead of contract_load_failed. The test keeps its point (only the loader's root checks mean contract_not_a_mapping) and now pins the typed outcome.
1 task done
fas89
added a commit
that referenced
this pull request
Oct 2, 2026
Adds fluid_build.api.load_contract (a contract file or a `fluid bundle` .tgz) and two in-memory forms, load_contract_from_text and load_contract_from_dict. Each returns a LoadedContract: - contract: the dict `fluid plan` plans, after parsing, $ref composition, the env overlay, alias rewrites and the legacy build: rewrite, in the engine's order. - digest: the plan-digest canonicalisation of that dict. - the files composed into it. Failures raise a typed ContractLoadError with a stable `event`. `env` must be an environment name, never a path: the engine builds overlay paths from it. The in-memory forms follow the engine's overlay-drop rule, and they unshare YAML aliases so that a rewrite cannot leak into aliased nodes. fluid_build.api is now version 1.1. A guard test fails if a new engine step can reach `contract` without going through this module, and it is negative-controlled over the engine's real source. With $ref confinement (#687) on main, a $ref holding a NUL byte is a typed ref error (contract_ref_unresolved), and the tests pin that. Docs: docs/CONTRACT_LOADING_API.md; the companion forge_docs page is in Agenticstiger/forge_docs#139.
fas89
added a commit
that referenced
this pull request
Oct 2, 2026
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A relative
$refwas joined to the contract's directory with../honoured. The only guards were "not absolute" and the system-directory deny list, so a contract could compose any dict-rooted YAML/JSON file the process can read into itself, andfluid bundleprinted it back. The downstream service runsfluid validate/bundleon contracts its users upload.Every external
$refnow goes through one check,fluid_build/util/ref_confinement.py::confine_ref:file://included) or a//hostform is treated as remote and refused.Path.resolve()-d (..and symlinks) and must beis_relative_to(root).The error is
RefConfinementError, aRefResolutionErrorsubclass, with.ref/.pointer/.source/.root.Widening the root is opt-in: pass
ref_root=toload_contract/load_with_overlay/compile_contract, or setFLUID_REF_ROOTfor the CLI.FLUID_REF_ROOTis process-wide. If it is not a directory or does not contain the contract, it is ignored for that contract with aref_root_env_ignoredWARNING, and the contract gets its own directory as the root (the same root as with the variable unset). So a variable set once in a shell or a service container never breaks or widens an unrelated contract, such as an upload materialised in a temp directory.ref_root=raises.Bundled OpenAPI fragments (
sources/openapi/*) go through the same check with no root. Any non-#/$refanywhere in the fragment, including example andx-*payloads, is anOAS-REF-EXTERNALerror, and openapi-spec-validator is not run on that fragment. Its default handlers followfile://andhttp(s)://. Keys are not skipped by name, becauseproperties: {example: {$ref: …}}is a schema whose$refthe validator does follow (checked against openapi-spec-validator 0.9.0 with an audit hook onopen()).$refconfinementMonorepos that shared fragments between products with
$ref: ../other-product/…(the old loader comment explicitly allowed../sibling/) now fail withescapes the ref root.Migration: set the ref root to a directory that holds the products and their shared fragments:
From Python:
load_contract(path, ref_root="<repo root>"). Contracts whose refs stay inside their own directory need no change. Seedocs/contract-refs.md#widening-the-root-monoreposand the new "Upgrading" subsection.Release notes (for CHANGELOG)
$reftargets are confined to the root contract's directory tree. URL and absolute refs are refused, and..and symlink escapes are refused after resolution. Widen withFLUID_REF_ROOT/ref_root=.FLUID_REF_ROOTis ignored, with aref_root_env_ignoredwarning, for a contract outside it. An unusableref_root=raises.fluid validate <bundle>.tgz: a bundled OpenAPI fragment with any external$ref(example payloads included) getsOAS-REF-EXTERNALinstead of being handed to openapi-spec-validator.Review follow-ups addressed (second commit)
FLUID_REF_ROOThard-failed every unrelated contract with a file$ref, contradicting the docs. It now falls back as described above, andref_root=stays strict.$refreuse. It now gives theFLUID_REF_ROOTstep and has a worked sibling-product section, checked against the CLI. The change is marked BREAKING above.$ref-in-example rejection is documented and pinned by tests rather than relaxed, because relaxing it by key name would let schema-position refs through.Docs
docs/contract-refs.md: new page.examples/0.7.1/bitcoin-multifile/README.mdhas a new section, "Sharing a fragment with another product".Borrowed, not built
The containment shape comes from:
is_relative_to(base_path);file://is treated as remote; widening is an explicit opt-in.NoSuchResource).Neither library composes YAML fragments with a JSON-pointer suffix, so the policy is borrowed and the resolver kept.
Tests
tests/test_loader_ref_confinement.py:.., absolute, Windows-shaped,file://, remote, symlinked file and directory, nested refs, and no existence probe.ref_root=-strict, and the env fallback for elsewhere and missing roots across all 3 entry points, which warns once and is never wider than the default.FLUID_REF_ROOTpointing elsewhere.tests/forge/test_openapi_external_refs.py: OAS-REF-EXTERNAL for URL, absolute and relative refs, for refs in data and schema positions, and against the real validator.tests/util/test_ref_confinement.py: unit tests forconfine_ref.pytest -n auto --dist loadscope --ignore=tests/perf, Python 3.12): 19728 passed, 637 skipped, 11 xfailed, 15 xpassed.ruff check fluid_build/ tests/passes, andblack==24.10.0 --check fluid_build/ tests/passes.Tested live
Decisions for the reviewer
__all__export line in fluid_build/loader.py it will touch the same file as this PR. This PR changes only_effective_ref_rootand adds two module-level names next to it, which is a separate region, so a 3-way merge should resolve cleanly. The orchestrator should still check whether the load-API PR edits loader.py's__all__.ref_root_env_ignoredwarning is logged once per (contract directory, value) per process, through_NOTED_REF_ROOT_ENV_IGNORED. A long-running service such as a downstream service backend sees a new temp directory for every upload, so it logs one WARNING per uploaded contract with a file $ref while FLUID_REF_ROOT is set. That is the intended signal, but operators who set the variable for a shared-fragments checkout will see it on every upload._contract_loader._auto_bundle_if_neededre-runscompile_contract(path)on the base contract whenever the loaded document still has any$ref— including a same-document#/...ref thatload_with_overlayintentionally leaves in place. The result replaces the overlaid contract, so the CLI path silently drops--envoverlays for such contracts. Reproduced:load_with_overlay('c.yaml','prod')['name']gives 'prod', but_contract_loader.load_contract_with_overlay('c.yaml','prod', log)['name']gives 'base'. The function also swallows compile errors at DEBUG level. Recommend a separate PR.--ref-rootCLI flag as well asFLUID_REF_ROOT? I left it out to stay inside the files this fix needs; adding it would touch validate/plan/apply/bundle argument parsing.fluid_build/util/ref_confinement.pybe added to the[[tool.mypy.overrides]] strict = truehotspot allowlist? It passesmypy --stricttoday. I did not add it because that means editing the sharedpyproject.toml.$refstays a literal dict, so it is not an escape vector, but overlays cannot compose fragments.docs/contract-refs.mdis ready to port.Changes after review
These changes came from adversarial review rounds; each finding was upheld by at least 2 of 3 independent skeptics before it was fixed. A final re-review of the branch found nothing further.
R1 — 38d3224
Follow-up: an escape after an ignored
FLUID_REF_ROOTexplains itselfFLUID_REF_ROOTwas ignored. IfFLUID_REF_ROOTis not a directory, or does not contain the contract, the loader ignores it for that contract. Theref_root_env_ignoredwarning is logged only once per process. Before this change, the escape error that followed told the user to "set FLUID_REF_ROOT to that directory", even though it was already set. Repeat loads in a long-running process, such as a downstream service worker, gave that advice with no warning at all. Now every escape error in this case saysFLUID_REF_ROOT is set but was ignored for this contract: <reason>.RefConfinementErroralso gainsignored_ref_root_env: the ignored value, orNone. A library caller can check it without parsing the message.docs/contract-refs.mdno longer says aFLUID_REF_ROOTleft in the shell "never widens another contract". It now says the variable is ignored for a contract outside it, and that every contract inside it is widened. The upgrade note sets the variable for a single command (FLUID_REF_ROOT=... fluid validate ...) instead of usingexport.load_contract,compile_contractandload_with_overlay, with the escape in a nested fragment. They check that both errors explain the ignored variable and that the warning is logged only once. The tests fail on the previous commit and against a mutant that explains only on the first load. A separate test checks that the generic hint is unchanged when the variable is unset or applied.R2 — 7d2cd0c
Follow-up: a FLUID_REF_ROOT that cannot be resolved, and warning dedupe
A broken value now falls back too. Before this, the
FLUID_REF_ROOTfallback only covered a value that resolved to a path that is not a directory, or to a directory that does not contain the contract. Three kinds of stale value raised before the fallback was reached:~olduser/repofor a user that no longer exists raisedRuntimeError.RuntimeError.PermissionErrorfromis_dir().The raw exception failed every contract that has a file
$ref. There was noref_root_env_ignoredwarning, the error never namedFLUID_REF_ROOT, andexcept RefResolutionErrorhandlers did not catch it.ref_root=raised the same raw errors instead of the documentedRefResolutionError.Now the root is resolved and checked inside
except (OSError, RuntimeError, ValueError). A failure becomes<origin>=<value> cannot be resolved: <reason>and takes the existing path:ref_root=raisesRefResolutionError, namingref_root.FLUID_REF_ROOT, the value is ignored for this contract with the warning, the contract gets the default root, and every escape error says the variable was ignored and why.The warning is now deduped on the value alone. The
ref_root_env_ignoreddedupe was keyed on (contract directory, value). A service that setsFLUID_REF_ROOTonce and loads each upload from a fresh temp directory added one permanent entry and logged one warning per upload. It now logs once per value per process and holds one key per value. A later contract's escape errors still carry their own explanation.docs/contract-refs.mdis updated to match.Tests. New parametrized tests cover an unknown
~user, a symlink loop and a chmod-000 parent (skipped on Windows and as root), through all three loader entry points, plusref_root=. A five-upload test checks there is one warning and one dedupe key. All 10 new cases fail against the previous loader, withRuntimeError,PermissionError, orassert 5 == 1, and pass with this change on Python 3.10 and 3.12.Tested live. With
FLUID_REF_ROOTset to each of the three broken values,fluid validate examples/0.7.1/bitcoin-multifile/contract.fluid.yamlnow prints the "cannot be resolved" warning and exits 0. Before this change it exited 1 with the raw error.Documentation
Companion docs PR: Agenticstiger/forge_docs#139 (new pages for
$refconfinement, the DuckDB sandbox and the contract-loading API, plus 0.18.0 release notes). It merges after 0.18.0 is on PyPI.