Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
63 changes: 63 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Fuzz

# Canonicalization is security-critical (`v0.1 §2.3`): two distinct actions sharing a canonical
# form share an approval, and nothing in a receipt would look wrong. `Policy.from_yaml` promises
# in its docstring that anything malformed raises `PolicyError`. Both are properties rather than
# examples, so both are fuzzed. `fuzz/properties.py` holds the invariants; this runs them.

on:
pull_request:
push:
branches: [main]
schedule:
- cron: "23 4 * * 1"

permissions:
contents: read

jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"

- name: Install
run: |
python -m pip install --upgrade pip
pip install -e .

# The gate. No Atheris, so it cannot be skipped by a toolchain problem, and it fails on
# a seed that regressed rather than on a campaign that happened to find one.
- name: The seed corpus still holds
run: |
python fuzz/fuzz_canonical.py --corpus
python fuzz/fuzz_policy.py --corpus

# Atheris is a real dependency and a failure to install it is a red build, not a skip:
# a fuzzing job that quietly stops fuzzing is the false green this repository keeps
# finding in other costumes.
- name: Install Atheris
run: pip install atheris

# Bounded, or an unbounded `atheris.Fuzz()` hangs until the job timeout, which reads as a
# broken build rather than as a finding. `-runs` is not used: wall-clock is what makes a
# pull request's campaign the same size on a fast and a slow runner.
- name: Campaign
run: |
python fuzz/fuzz_canonical.py -max_total_time=120 -print_final_stats=1 fuzz/corpus/canonical
python fuzz/fuzz_policy.py -max_total_time=120 -print_final_stats=1 fuzz/corpus/policy

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: crashes
path: |
crash-*
timeout-*
oom-*
if-no-files-found: ignore
retention-days: 14
6 changes: 6 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,10 @@ prune .github
# a test, because `MANIFEST.in` resolves against the working tree rather than the index, which
# is how v0.2 shipped four files it should not have.
prune adapters
# `fuzz/` is a development tree like `research/`: the targets, their seed corpus and the
# invariants they assert. Belt and braces for the same reason the four lines above are --
# `[tool.setuptools.packages.find]` looks only in `src/`, so nothing here is a package and the
# sdist omits it either way. `test_the_fuzz_corpus_is_not_packaged` asserts the line, and
# `test_T181_*` asserts the outcome.
prune fuzz
global-exclude *.py[cod] .DS_Store
92 changes: 92 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Fuzzing

Two properties are fuzzed, chosen because each is a promise the project has written down.

**Canonicalization** (`SPEC-v0.1.md` §2.3, which calls it security-critical and requires a
test proving old hashes still verify before anything here changes). Two distinct actions sharing a canonical form share an approval, and
nothing in a receipt would look wrong. This repository has already had one bug of exactly that
shape — `str(key)` folded `{"1": "a", 1: "b"}` into a single key, with the survivor decided by
insertion order — and a review found it. These are the properties that would have found it
without one.

**`Policy.from_yaml`**, whose docstring says *anything malformed raises `PolicyError`*. That is
the fail-closed rule in a sentence. A loader raising `KeyError` still denies, but through a
crash nobody classified, and a caller catching `PolicyError` would not catch it.

## Layout

```
properties.py the invariants and the decoders — no Atheris import
fuzz_canonical.py Atheris entry point
fuzz_policy.py Atheris entry point
corpus/ seed inputs, including a reproducer for every recorded finding
```

`properties.py` deliberately does not import Atheris. The fuzzer is one driver for these
invariants and `tests/test_fuzzing.py` is another, so they run on every commit whether or not
anybody has a fuzzing toolchain — and a contributor can reproduce a finding with nothing but
the standard library.

## Running

```sh
python fuzz/fuzz_canonical.py --corpus # seeds only, no Atheris
pip install atheris
python fuzz/fuzz_canonical.py -max_total_time=120 fuzz/corpus/canonical # a campaign
```

CI runs the first form as a gate and the second for two minutes per target on every pull
request, weekly on a schedule. A failure to install Atheris is a red build and not a skip: a
fuzzing job that quietly stops fuzzing is a false green.

## The invariants

For canonicalization, over every generated document:

1. **Refusal is stated.** Nothing but `InvalidArgument` escapes — checked on *every* call, not
just the first. A positive control found that one: an encoder broken only on its second call
raised `TypeError` straight out of the property.
2. **Floats and non-string keys are refused**, asserted *before* the call. Checking only what
comes back cannot tell "refused correctly" from "never refused at all".
3. **Deterministic** — the same document twice gives the same bytes.
4. **Order-independent** — two mappings that compare equal encode identically, or the hash
depends on how the caller happened to build the dict.
5. **Stable through a round trip** — `json.loads` then re-encode gives the same bytes. A lossy
encoding shows up here.
6. **Keys sorted at every level**, which is what makes the form canonical and not merely
consistent.

For the policy loader: nothing but `PolicyError` escapes, and a document that loads hashes the
same way on a second parse. (`RecursionError` on a document nested past the interpreter's limit
is out of scope, and is returned rather than swallowed silently — it is a stack-depth property,
not a policy one.)

Every invariant has a positive control in `tests/test_fuzzing.py` that breaks it deliberately
and requires the check to notice. A property that cannot fail proves nothing.

## Findings

`properties.KNOWN_FINDINGS` records defects that are real, reported, and not yet fixed. They
are recorded rather than worked around in the decoder, because a fuzzer whose corpus is pruned
to avoid its own findings reports zero forever.

`test_the_known_findings_still_reproduce` asserts each one **still happens**. The day it is
fixed that test goes red and the entry must be deleted — which is the point. A recorded limit
that quietly starts passing is the failure mode this directory is about.

### `lone-surrogate-in-a-string`

`canonical_bytes` raises `UnicodeEncodeError`, not `InvalidArgument`, for a string holding an
unpaired UTF-16 surrogate.

Reachable: `json.loads('"\ud800"')` produces one, so an MCP tool call can carry it into the
action path. `Action(...)` accepts it and `action_hash` is where it fails.

Fail-closed **holds** — both the gateway and `Control` wrap the action path in
`except Exception` — so this is a contract violation and a crash, not an authorization bypass.
What it breaks is the closed error set in `errors.py` and `InvalidArgument`'s documented
promise: a caller catching `CTRLRunError` does not catch this.

The fix is narrow and belongs to whoever owns `src/`: reject unencodable strings in
`_no_floats` alongside the float and non-string-key checks. It changes no hash that previously
succeeded, so `v0.1 §2.3`'s "old hashes still verify" rule is satisfied without a schema bump.
Empty file added fuzz/corpus/canonical/seed-00
Empty file.
Binary file added fuzz/corpus/canonical/seed-01
Binary file not shown.
1 change: 1 addition & 0 deletions fuzz/corpus/canonical/seed-02
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ÿÿÿÿÿÿÿÿ
Binary file added fuzz/corpus/canonical/seed-03
Binary file not shown.
1 change: 1 addition & 0 deletions fuzz/corpus/canonical/seed-04
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 1 addition & 0 deletions fuzz/corpus/canonical/seed-05
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Binary file added fuzz/corpus/canonical/seed-06
Binary file not shown.
Binary file added fuzz/corpus/canonical/seed-07
Binary file not shown.
Binary file added fuzz/corpus/canonical/seed-08
Binary file not shown.
1 change: 1 addition & 0 deletions fuzz/corpus/canonical/seed-09
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 1 addition & 0 deletions fuzz/corpus/canonical/seed-10-surrogate
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ÏÉüÂÚ1Î=Ñf½Í:3„~[»ý
Empty file added fuzz/corpus/policy/seed-00
Empty file.
2 changes: 2 additions & 0 deletions fuzz/corpus/policy/seed-01
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: ctrlrun.policy/v3
actions: {}
4 changes: 4 additions & 0 deletions fuzz/corpus/policy/seed-02
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
schema: ctrlrun.policy/v3
actions:
pay:
default: deny
1 change: 1 addition & 0 deletions fuzz/corpus/policy/seed-03
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1: true
1 change: 1 addition & 0 deletions fuzz/corpus/policy/seed-04
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[ [ [ [
1 change: 1 addition & 0 deletions fuzz/corpus/policy/seed-05
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
schema: ctrlrun.policy/v99
Binary file added fuzz/corpus/policy/seed-06
Binary file not shown.
1 change: 1 addition & 0 deletions fuzz/corpus/policy/seed-07
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
actions: not-a-mapping
5 changes: 5 additions & 0 deletions fuzz/corpus/policy/seed-08
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
schema: ctrlrun.policy/v3
actions:
pay:
default: allow
require_approval: true
72 changes: 72 additions & 0 deletions fuzz/fuzz_canonical.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Atheris entry point for the canonical property. The invariants live in `properties.py`.

Two ways to run it:

python fuzz/fuzz_canonical.py --corpus # the seed corpus, no Atheris needed
python fuzz/fuzz_canonical.py -max_total_time=60 fuzz/corpus/canonical

The first is what `tests/test_fuzzing.py` and CI's quick gate run, so the target is executed
on every commit rather than only wherever a fuzzing toolchain happens to exist.
"""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Any

FUZZ = Path(__file__).resolve().parent
if str(FUZZ) not in sys.path:
sys.path.insert(0, str(FUZZ))

# **`properties` is deliberately not imported here.** `atheris.instrument_imports()` hooks the
# import system and instruments what is imported *inside* it, transitively -- so `properties`,
# and through it `ctrlrun.action`, has to reach the interpreter for the first time in that
# block. An import at module scope puts it in `sys.modules` first and makes the instrumented
# re-import a silent no-op: the campaign still runs, at full speed, guided by nothing at all.
# `stat::new_units_added: 0` after ten million executions is what that looks like in a CI log,
# and it is what the first version of this file did.
properties: Any = None


def one_input(data: bytes) -> None:
properties.check_canonical(properties.document_from_bytes(data))


def _run_corpus() -> int:
seeds = sorted(p for p in (FUZZ / "corpus" / "canonical").iterdir() if p.is_file())
findings: dict[str, int] = {}
for path in seeds:
known = properties.check_canonical(properties.document_from_bytes(path.read_bytes()))
if known is not None:
findings[known] = findings.get(known, 0) + 1
print(f"canonical: {len(seeds)} inputs, {len(findings)} known finding(s) reproduced")
for name, count in sorted(findings.items()):
print(f" {name}: {count}")
return 0


def main() -> int:
if "--corpus" in sys.argv:
import properties as module

globals()["properties"] = module
return _run_corpus()

import atheris

# The first import of `properties` in this process, so the instrumentation actually
# applies to it and to `ctrlrun` underneath it.
with atheris.instrument_imports():
import properties as module

globals()["properties"] = module

atheris.Setup(sys.argv, one_input)
atheris.Fuzz()
return 0


if __name__ == "__main__":
raise SystemExit(main())
72 changes: 72 additions & 0 deletions fuzz/fuzz_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Atheris entry point for the policy property. The invariants live in `properties.py`.

Two ways to run it:

python fuzz/fuzz_policy.py --corpus # the seed corpus, no Atheris needed
python fuzz/fuzz_policy.py -max_total_time=60 fuzz/corpus/policy

The first is what `tests/test_fuzzing.py` and CI's quick gate run, so the target is executed
on every commit rather than only wherever a fuzzing toolchain happens to exist.
"""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Any

FUZZ = Path(__file__).resolve().parent
if str(FUZZ) not in sys.path:
sys.path.insert(0, str(FUZZ))

# **`properties` is deliberately not imported here.** `atheris.instrument_imports()` hooks the
# import system and instruments what is imported *inside* it, transitively -- so `properties`,
# and through it `ctrlrun.action`, has to reach the interpreter for the first time in that
# block. An import at module scope puts it in `sys.modules` first and makes the instrumented
# re-import a silent no-op: the campaign still runs, at full speed, guided by nothing at all.
# `stat::new_units_added: 0` after ten million executions is what that looks like in a CI log,
# and it is what the first version of this file did.
properties: Any = None


def one_input(data: bytes) -> None:
properties.check_policy(properties.policy_text_from_bytes(data))


def _run_corpus() -> int:
seeds = sorted(p for p in (FUZZ / "corpus" / "policy").iterdir() if p.is_file())
findings: dict[str, int] = {}
for path in seeds:
known = properties.check_policy(properties.policy_text_from_bytes(path.read_bytes()))
if known is not None:
findings[known] = findings.get(known, 0) + 1
print(f"policy: {len(seeds)} inputs, {len(findings)} known finding(s) reproduced")
for name, count in sorted(findings.items()):
print(f" {name}: {count}")
return 0


def main() -> int:
if "--corpus" in sys.argv:
import properties as module

globals()["properties"] = module
return _run_corpus()

import atheris

# The first import of `properties` in this process, so the instrumentation actually
# applies to it and to `ctrlrun` underneath it.
with atheris.instrument_imports():
import properties as module

globals()["properties"] = module

atheris.Setup(sys.argv, one_input)
atheris.Fuzz()
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading