Skip to content

A document PyYAML cannot convert is refused in words, not raised as OverflowError - #142

Merged
arpanghoshal merged 1 commit into
mainfrom
fix/yaml-loader-escapes
Sep 11, 2026
Merged

arpanghoshal merged 1 commit into
mainfrom
fix/yaml-loader-escapes

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

Found by fuzz on #140, which is how a non-required check earns its place: the failure had nothing to do with that PR and everything to do with main.

What escapes strict_load

Policy.from_yaml promises PolicyError for anything malformed, without qualification, and strict_load is the one loader every CTRLRun document goes through. PyYAML converts a scalar before it has decided the document is well formed, so these come back as the interpreter's own exception and go straight past except yaml.YAMLError:

document raised how you reach it
"\Ueeeeeeeeeeeeeeeee… OverflowError: Python int too large to convert to C int the fuzzer, after 174,380 executions
"\U00110000" ValueError: chr() arg not in range(0x110000) an escape just above the Unicode maximum
x: 2026-99-99 ValueError: month must be in 1..12 a mistyped date in a real policy

The third is the one that matters. The first two need a fuzzer or a bad day; a wrong month is an ordinary typo somebody writes by hand.

What this does and does not fix

Nothing unsafe was ever admitted: the document is refused either way. What leaked is the exception type, and the type is the contract. A caller wrapping a policy load in except PolicyError caught none of these.

ValueError and OverflowError are refusals too now. Deliberately not except Exception: strict_load calls one thing, so a MemoryError or KeyboardInterrupt there is not the document's fault and must not be reported as one. RecursionError stays uncaught for the same reason fuzz/properties.py already excludes it.

Tests

All three cases, with the first seeded from the fuzzer's own crash unit decoded exactly as policy_text_from_bytes decodes it, so the test fails if either the decode path or the loader changes. Replaying the crash unit through properties.check_policy now passes.

Local: 2,948 passed, 65 skipped. ruff format --check, ruff check and mypy --strict src clean.

🤖 Generated with Claude Code

…verflowError

`Policy.from_yaml` promises `PolicyError` for anything malformed, without
qualification, and `strict_load` is the one loader every CTRLRun document goes
through. PyYAML converts a scalar before it has decided the document is well
formed, so three conversions came back as the interpreter's own exception and
went straight past `except yaml.YAMLError`:

- an over-long `\U` escape overflows converting the codepoint to a C int, which
  is `OverflowError`. The fuzzer found this after 174,380 executions;
- `"\U00110000"` is above the Unicode maximum and `chr()` says `ValueError`;
- `x: 2026-99-99` is a `ValueError` from `datetime`, and is the one that
  matters: a mistyped month is a thing a person writes in a real policy.

Nothing unsafe was admitted -- the document is refused either way -- so what
leaked is the exception type, and the type is the contract: a caller catching
`PolicyError` around a policy load caught none of these.

`ValueError` and `OverflowError` are refusals too now. Deliberately not
`except Exception`: this function calls one thing, so a `MemoryError` here is
not the document's fault. `RecursionError` stays uncaught for the same reason
`fuzz/properties.py` excludes it.

Regression tests cover all three, the first seeded from the fuzzer's own crash
unit decoded as the harness decodes it, so the test fails if either the decode
path or the loader changes. The crash unit now passes `check_policy`.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 37 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 50b56f1e-f319-4b30-97d5-761f897cabd5

📥 Commits

Reviewing files that changed from the base of the PR and between 41a79b1 and f1fe782.

📒 Files selected for processing (2)
  • src/ctrlrun/policy.py
  • tests/test_policy.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@arpanghoshal

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

arpanghoshal added a commit to CTRLRun/ctrlrun-docs that referenced this pull request Sep 11, 2026
CTRLRun/ctrlrun#142 documents why `strict_load` treats `ValueError` and
`OverflowError` as refusals, and the docstring is rendered: `Policy.mdx` and
`parse_conditions.mdx` follow it. The three regression tests move the collected
count to 4,513, counted under the docs job's install line rather than this
laptop's, and eleven CLAIMS citations move with the lines the docstring pushed
down.

Generated throughout: render_api, render_readiness, repoint-claims. Nothing
here is hand-written.
@arpanghoshal
arpanghoshal merged commit 36915dc into main Sep 11, 2026
23 of 24 checks passed
@arpanghoshal
arpanghoshal deleted the fix/yaml-loader-escapes branch September 11, 2026 16:49
arpanghoshal added a commit that referenced this pull request Sep 11, 2026
Integrated by merge rather than by rebase, and the reason is written down: this
branch's reviewed commit is already on the remote, so a rebase could only reach
it by force, and that is forbidden here. The tree is the one a rebase onto
origin/main produces.

The conflicts were the catalogue and the counts it feeds. G13 and G16 both land
in ctrlrun.guarantees/v3, in id order. On SQLite G13 is N/A, because SQLite has
no clock of its own, and G16 is graded, so examples/authority/payments.yaml is
12/12 with one not applicable and examples/policies/payments.yaml is 7/7 with
six; ci.yml, the verify tests and the report tests carry those numbers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant