Skip to content

Run the price importers with least privilege and refuse implausible responses - #541

Open
dsfaccini wants to merge 4 commits into
mainfrom
harden-price-importers
Open

Run the price importers with least privilege and refuse implausible responses#541
dsfaccini wants to merge 4 commits into
mainfrom
harden-price-importers

Conversation

@dsfaccini

@dsfaccini dsfaccini commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

This pull request was posted by Claude Code using claude-opus-5 on behalf of David. David reviewed it lightly.

Closes #538. Independent of #533/#536 — branched from main, no overlap.

Housekeeping on the six price importers: run the one that executes upstream code with least privilege, and make the ones that rewrite tracked files refuse an implausible response instead of writing it.

Least privilege for the helicone task

prices/helicone_get/main.ts imports cost/providers/mappings.ts, which is Helicone's TypeScript copied in by pull.sh. Importing it means upstream code runs during make helicone-get. None of the other five importers do that — they parse inert JSON.

The task ran with a bare --allow-write, which grants write access to every path the invoking user has. main.ts writes exactly one path (../source_prices/helicone.json), so the broad permission bought nothing. It is now --allow-write=../source_prices.

Deno denies net, read, run and env by default and none are granted, so with write scoped this task can only touch the file it is meant to produce. Verified from prices/helicone_get:

inside scope (../source_prices): ALLOWED
home dir:                        blocked (NotCapable)
repo root:                       blocked (NotCapable)
git hooks dir:                   blocked (NotCapable)

pull.sh switches the clone from SSH to HTTPS — it is a public repo, so there is no reason to use an authenticated agent. prices/helicone_get/README.md records why the write scope is narrow so it does not get widened later by accident.

Refuse implausible imports

Four importers overwrite tracked prices/providers/*.yml from live responses, and those files are what the published artifacts are built from. The only gate is someone reading git diff, which is least reliable exactly when the diff is large.

New prices/src/prices/source_guard.py:

  • check_non_empty — refuse an import that produced no models. This is the failure mode from Post-v2 audit: four broken pipeline paths + the verification gaps that let them ship green #533: an upstream changed shape, the parser dropped everything, and the importer wrote {} and exited 0.
  • check_no_sharp_drop — refuse a rewrite that would delete more than half of a provider's recorded models.
  • GENAI_PRICES_ALLOW_IMPLAUSIBLE_IMPORT=1 downgrades both to warnings, for when a provider genuinely did retire its catalogue.

Wired into write_source_prices — the chokepoint every JSON-writing importer passes through, covering simonw, litellm, openrouter and aws in one place — and into the source_huggingface / source_ovhcloud paths that write provider YAML directly. Both build and validate all their output before writing any of it, so a failure on one provider cannot leave others half-updated.

These catch the two loudest upstream failure modes and nothing subtler. A plausible-looking response with wrong numbers still needs a human to notice.

Also

  • source_ovhcloud.py swallowed every exception into a silent return — a failed fetch printed a line and exited 0, indistinguishable from a successful no-op. It raises now.
  • source_huggingface.py called .json()['data'] with no raise_for_status(), so an error page surfaced as a KeyError.

Verification

766 tests pass, including 9 new ones for the guards. ruff, basedpyright strict and the full pre-commit set pass. make helicone-get, make ovhcloud-get and make huggingface-get all run against their live APIs: the guards do not false-positive on real responses, and they do fire on the real recorded counts — huggingface_novita has 61 models on record, so a response yielding 0 or 2 is refused and 60 is written.

Running those importers surfaced genuine price churn plus two new HuggingFace providers (deepinfra, scaleway). That is deliberately not included here — it belongs in its own data PR.

Review feedback applied

  • Timeouts removed. I had added timeout=30.0 to four importers on the belief they were unbounded. They were not: httpx2 2.2.0 ships DEFAULT_TIMEOUT_CONFIG = Timeout(timeout=5.0) applied per operation, so the change made them six times slower to give up than the default. Reverted, and the claim corrected on Importer housekeeping: least privilege for the helicone task, plausibility checks before writing provider YAML #538. Thanks @Kludex. source_ovhcloud.py keeps its pre-existing timeout=30.0, which predates this branch.
  • SHA pin dropped. An earlier revision pinned the Helicone clone to a commit. It added recurring friction for a speculative benefit, so pull.sh tracks main again as before.
  • Wording. Reworded the in-repo notes in terms of least privilege and upstream breakage rather than adversarial framing.

Scope decisions, flagged as deliberate

Closes #538.

The helicone importer was the only code-execution vector in the repo. pull.sh
cloned Helicone/helicone at unpinned main, and main.ts *imports*
cost/providers/mappings.ts - so third-party TypeScript executed locally, under
deno run --allow-write with no path scope. That is an arbitrary-write primitive
as the invoking user, and arbitrary write becomes code execution through a shell
rc file or a git hook. No CI job runs any *-get target, so the exposure was a
maintainer's laptop rather than a runner.

Two changes close it, verified by probing the real config:

  inside scope (../source_prices): ALLOWED
  home dir:                        blocked (NotCapable)
  repo root:                       blocked (NotCapable)
  git hooks dir:                   blocked (NotCapable)

- Scope --allow-write to ../source_prices. main.ts writes exactly one path, so
  this costs nothing. Deno already denies net/run/read/env, so with write scoped
  a compromised upstream can only put bad numbers in a gitignored JSON file -
  the same trust level as every other source, already gated by review.
- Pin the clone to a commit SHA and fetch over HTTPS rather than handing it an
  authenticated SSH agent. Bumping the pin is now a reviewable commit instead of
  something that happens silently on every run.

Data-integrity guards, for the four importers that overwrite tracked provider
YAML - which is what the published artifacts are built from, gated only by
someone reading the diff:

- New source_guard module. check_non_empty refuses an import that produced no
  models; check_no_sharp_drop refuses a rewrite that would delete more than half
  of a provider's recorded models. GENAI_PRICES_ALLOW_IMPLAUSIBLE_IMPORT=1
  overrides when a shrink is genuine.
- Wired into write_source_prices, which is the chokepoint for every importer
  that writes a JSON source file, and into the huggingface and ovhcloud paths
  that write provider YAML directly.

Also, from the same review:

- ovhcloud swallowed every exception into a silent return, so a failed fetch
  looked exactly like a successful no-op. It raises now.
- huggingface called .json()['data'] with no raise_for_status, turning an error
  page into a KeyError.
- The four importers with no HTTP timeout now have one.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 11 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread prices/src/prices/source_huggingface.py Outdated
Comment thread prices/src/prices/source_ovhcloud.py Outdated
Comment thread prices/src/prices/source_huggingface.py Outdated
…ers before writing any

All three findings were valid, and the first two share a root cause: the guards
counted the raw response instead of what parsing actually produced, so the one
failure mode they exist for - an upstream shape change - would have walked
straight past them. A large `models` list that yields zero usable ModelInfos
passed `check_non_empty` and then wrote nothing, silently, exit 0.

- huggingface: count the ModelInfos built from the response, not `len(models)`.
- ovhcloud: same, and move the check after `get_model_infos`. The old
  `if not model_infos: print(...); return` path exited 0 on a total parse
  failure, which is indistinguishable from a successful import.
- huggingface: build every provider's YAML into a pending list, validate all of
  them, then write. Validating inside the write loop meant a sharp drop on a
  later provider left earlier ones already overwritten - a partial update that
  is worse than either outcome.

Verified against the live APIs: both importers still complete and the guards do
not false-positive on real responses (huggingface even picked up two new
providers, whose data churn is deliberately not included here). Verified the
guards fire on the real recorded counts - 61 models on record, 0 or 2 returned
is refused, 60 is written.
Marcelo is right, and the change was worse than redundant. httpx2 2.2.0 ships
DEFAULT_TIMEOUT_CONFIG = Timeout(timeout=5.0), applied to connect/read/write/pool
individually, so the importers were never unbounded. Passing timeout=30.0 made
them six times slower to give up than the default they already had.

The claim in #538 that a hung upstream hangs the command indefinitely was simply
wrong; corrected there.

source_ovhcloud.py keeps its pre-existing timeout=30.0 - that predates this
branch and is someone's deliberate choice, not mine to reverse here.
Two pieces of review feedback.

The pin added recurring friction - someone has to bump it - for a speculative
benefit, so pull.sh goes back to tracking main. The remote stays on HTTPS rather
than SSH: this is a public repo, so there is no reason for the clone to use an
authenticated agent, and that costs nothing either way.

The scoped --allow-write stays. main.ts writes exactly one path, so the narrow
form is free, and it keeps an unexpected upstream change contained to the
gitignored output file every other price source writes.

Also reworded the deno.json note, the README and the source_guard docstring in
terms of least privilege and upstream breakage rather than adversarial framing,
per Marcelo - that discussion belongs in a private channel, not in a public repo.
@dsfaccini dsfaccini changed the title Harden the price importers against a compromised upstream Run the price importers with least privilege and refuse implausible responses Aug 1, 2026
@dsfaccini

Copy link
Copy Markdown
Contributor Author

This comment was posted by Claude Code using claude-opus-5 on behalf of David.

@Kludex — both points taken, PR updated.

Timeouts: you're right, and it was worse than redundant. httpx2 2.2.0 ships DEFAULT_TIMEOUT_CONFIG = Timeout(timeout=5.0), applied per operation, and httpx2.get's timeout parameter defaults to it — so the importers were never unbounded. My timeout=30.0 made them six times slower to give up than the default they already had. Removed in ed6e57a, and the claim corrected on #538. source_ovhcloud.py keeps its pre-existing timeout=30.0 since that predates this branch.

Scope: dropped the SHA pin, kept the write scoping. The pin added recurring friction — someone has to bump it — for a speculative benefit, so pull.sh tracks main again. What's left of that half is one flag: --allow-write--allow-write=../source_prices. main.ts writes exactly one path, so the narrow form costs nothing and keeps an unexpected upstream change contained to the same gitignored output file every other importer writes.

Wording: retitled the PR and rewrote both it and #538 in terms of least privilege and upstream breakage. Agreed that the framing didn't belong in a public repo — the in-repo comments are reworded too.

Worth flagging one thing I can't clean up: the earlier commit messages on this branch still carry the original framing, and rewriting them would mean a force-push. Happy to squash-merge so only the PR title lands on main, if you'd prefer that.

On whether it saves anyone — fair challenge, and the honest answer is it doesn't protect any user of the package, since nobody but us runs make helicone-get. My case for the one flag is just that it's free: main.ts only ever writes that one path, and Deno already denies net/read/run/env, so the narrow grant costs nothing and the broad one buys nothing. The rest of the PR (the plausibility guards) is really about the #533 class of problem — an importer that silently writes nothing and exits 0 — rather than anything adversarial. Glad to split those into a separate PR if you'd rather this one be a single-flag change.

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.

Importer housekeeping: least privilege for the helicone task, plausibility checks before writing provider YAML

1 participant