diff --git a/.claude/rules/development.md b/.claude/rules/development.md index 7f22336..0ba8bab 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -25,6 +25,38 @@ Provider verification services and provenance APIs are development-only oracles. their SDKs in the development extra and their adapters out of the installed CLI, top-level Python API, and runtime detection/removal pipelines. +The unified maintainer entry point is `scripts/provider_oracles.py`; its workflow and +slot schema live in `docs/provider-oracles.md`. Multi-account and multi-network work +selects one named slot explicitly from the gitignored repository-local +`.oracle-slots.json` by default and snapshots it into the immutable batch manifest. +Never commit the local slot file. +Do not add account or IP rotation, rate-limit failover, or cross-session result merging. +Every manual result stays bound to the exact sanitized upload hash and preserves the +provider response verbatim. + +Google Web oracle automation uses only the user's existing authenticated real Chrome +session and explicit `/u/N/` account index. OpenAI, Microsoft, and Meta Web automation +uses isolated Playwright contexts; it must never attach to or copy state from the user's +real browser. Proxy credentials are read only through a slot's named environment variable +and never enter a manifest or log. Dotenv interpolation may derive routed proxy URLs from +one secret key without mutating the process environment. A selected proxy context may +accept that proxy's TLS interception certificate; direct contexts keep normal TLS checks. +Upload readiness is surface-specific: OpenAI reaches it at page load but keeps background +requests alive, while Microsoft and Meta require network idle. Preserve exact provider +errors as indeterminate evidence rather than retrying or interpreting them as clean. + +OpenAI API slots likewise carry only `api_key_env`. Store multiple key values in the +gitignored `.env`, select one slot explicitly, and never rotate to another token after a +refusal or rate limit. The process environment may override the matching `.env` value. +Their SDK transport sets `trust_env=False`; environment proxy variables must not reroute +an API check. + +Microsoft API slots use the Azure Content Safety and private Blob Storage resource +identities from the gitignored `.env`. The adapter reads resource keys only into memory +through authenticated Azure CLI, downloads and hashes the private blob before requiring +equality with the named local source, keeps `trust_env=False`, never retries the submit, +and reports Watermark independently from C2PA. + Do not add an option whose only outcome is an error. Model id, step count and CFG are fixed by the profile, so none of them is a parameter of the CLI, `InvisibleEngine`, or `WatermarkRemover` -- they were accepted-then-rejected for a while, which moved the failure several frames below the caller and advertised choices the pinned stack cannot honor. If a value cannot vary, delete the knob rather than validating it. `device` is the deliberate exception and stays a library parameter: `None`/`"auto"` detect, `"cuda"` pins without detecting, and everything else raises at construction. On the image path it is not a CLI option, because the only value a user could usefully type is the one auto-detection already finds. The video SynthID commands (`video invisible`, `video all`, `video batch`) do expose `--device`, whose VAE runs on cuda, mps, or cpu, so a user can usefully pick one. `test_device_exists_exactly_where_the_skill_says_it_does` pins that split; do not read this paragraph as licence to delete the video flag. diff --git a/.claude/skills/provider-oracles/SKILL.md b/.claude/skills/provider-oracles/SKILL.md new file mode 100644 index 0000000..a484ec6 --- /dev/null +++ b/.claude/skills/provider-oracles/SKILL.md @@ -0,0 +1,107 @@ +--- +name: provider-oracles +description: Run hash-bound Google, OpenAI, Microsoft, or Meta watermark-oracle checks for this repository. Use for provider verification, multi-account Gemini checks, isolated Playwright checks, or recording oracle evidence. +--- + +# Provider oracles + +Read `docs/provider-oracles.md` before acting. Use `scripts/provider_oracles.py` +for surface discovery, slot validation, upload preparation, result recording, +and integrity verification. + +Named slots live in the gitignored repository-local +`.oracle-slots.json`. Never commit that file. Pass `--slot NAME` to use it; +pass `--slots PATH` only to override the local path. + +Never substitute one provider's negative verdict for another provider's signal. +Do not optimize candidates adaptively against an external oracle. A retry, +different account, or different network is a new explicit operator decision, +not automatic fallback. + +## Route by surface + +### Google Gemini + +Use the user's real authenticated Chrome. Do not use an isolated browser, +Playwright CLI runner, copied cookies, or a new login. + +1. Read `~/Documents/GitHub/claude-config/docs/browser-automation.md` and select + the authenticated-Chrome capability. +2. Validate the named slot. Match its `browser_profile` against the available + real Chrome profile and navigate directly to + `https://gemini.google.com/u//app`. +3. Prepare an immutable `gemini-web` batch outside the repository. +4. Before each upload, request the action-time confirmation required for sending + that exact prepared file to Gemini. Do not upload the source path directly. +5. Attach one prepared upload and ask: `Was this image/video/audio created or + edited by Google AI?` +6. Read the settled SynthID tool outcome, not the surrounding model reasoning. + Preserve unclear, quota, and refusal wording rather than interpreting it as + clean. +7. Record the verbatim response and verify the completed batch. + +If the selected Chrome profile or `/u/N/` account is not already authenticated, +stop and ask the user to select the intended existing session. Do not sign in or +switch to another account implicitly. + +### OpenAI, Microsoft, and Meta Web + +Use the isolated Playwright runner, never the user's real Chrome: + +```bash +uv run python scripts/provider_oracles.py run-web /outside/repo/batch/manifest.json \ + --acknowledge-uploads --env-file .env +``` + +The runner is headed by default because OpenAI's Cloudflare gate blocks the +same isolated Chromium in headless mode. Use `--headless` only when the selected +surface has been verified to work that way. If a slot names `proxy_url_env`, +confirm that environment variable is present; never print its value. The runner +uses one context, one route, fresh page navigation per upload, and no retry. +CAPTCHA, bot checks, throttling, transport failure, and unknown page wording +remain distinct recorded outcomes. + +Wait for the surface-specific upload readiness that the runner implements. +OpenAI completes at page load but keeps background requests alive, while +Microsoft and Meta require network idle. Do not replace those conditions with +one shared wait. Treat OpenAI's exact `Something went wrong` response as a +settled `indeterminate` result, not as a clean result or a reason to retry. + +For ThorData residential proxy mode, keep `THORDATA_ROUTE_US`, +`THORDATA_ROUTE_DE`, `THORDATA_ROUTE_GB`, `THORDATA_ROUTE_NL`, and +`THORDATA_ROUTE_JP` URLs in the gitignored `.env`; +OpenAI, Microsoft, and Meta Web slots may each name one of them through +`proxy_url_env`. Encode an explicit two-letter ISO country code in each route, +verify egress before relying on it, and never automatically change route after +a refusal. Bound a batch before starting. + +### OpenAI API + +Use `check-openai` with one explicit upload acknowledgement. Keep named API keys +as `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, and `OPENAI_API_KEY_3` in the local `.env`, and select +only their variable names through an `openai-api` slot's `api_key_env`. Never +print a key, copy one into a slot or manifest, or fall back automatically to a +second key. API slots use a direct SDK transport that ignores environment proxy +settings. The current adapter is image-only. + +### Microsoft API + +Use `check-microsoft` with one explicit acknowledgement and one private Azure +Blob HTTPS URI whose bytes exactly match the local source. Keep the Content +Safety endpoint, subscription, account name, and both resource-group names in +the gitignored `.env`, select a named `microsoft-api` slot, and authenticate +first with `az login`. Give the Content Safety system identity `Storage Blob +Data Reader` on the input storage account. The adapter reads resource keys only +into memory through Azure CLI, never logs them, ignores environment proxy +settings, makes one submit request with no retry, and polls only the returned +operation. Keep Microsoft's Watermark and C2PA markers as separate verdicts. + +## Evidence rules + +- Upload only files under the prepared batch's `uploads/` directory. +- Keep `manifest.json` and `manifest.sha256` immutable. +- Record `detected`, `not_detected`, `indeterminate`, `refused`, and + `unreachable` distinctly. +- Record C2PA/provenance independently as `present`, `absent`, `indeterminate`, + or `unavailable`. +- Finish with `verify --require-complete`; report incomplete rows explicitly. diff --git a/.env.example b/.env.example index 8cd9964..dea13ba 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,24 @@ # OpenAI API key (optional; only for explicit verify-openai-synthid uploads) # Create and manage keys at: https://platform.openai.com/api-keys # OPENAI_API_KEY= + +# Named OpenAI keys for provider-oracle slots (optional; keep each value secret) +# OPENAI_API_KEY_1= +# OPENAI_API_KEY_2= +# OPENAI_API_KEY_3= + +# Azure Content Safety resource for direct Microsoft Content Provenance checks +# Resource keys are read into memory through the authenticated Azure CLI. +# AZURE_CONTENT_SAFETY_ENDPOINT=https://your-resource.cognitiveservices.azure.com/ +# AZURE_CONTENT_SAFETY_SUBSCRIPTION_ID= +# AZURE_CONTENT_SAFETY_RESOURCE_GROUP= +# AZURE_CONTENT_SAFETY_ACCOUNT_NAME= +# AZURE_ORACLE_STORAGE_RESOURCE_GROUP= + +# ThorData residential proxies (optional; only for explicitly selected Web oracle routes) +# Generate these URLs in the ThorData endpoint generator; do not commit credentials. +# THORDATA_ROUTE_US=http://td-customer-USERNAME-country-US:PASSWORD@HOST.pr.thordata.net:9999 +# THORDATA_ROUTE_DE=http://td-customer-USERNAME-country-DE:PASSWORD@HOST.pr.thordata.net:9999 +# THORDATA_ROUTE_GB=http://td-customer-USERNAME-country-GB:PASSWORD@HOST.pr.thordata.net:9999 +# THORDATA_ROUTE_NL=http://td-customer-USERNAME-country-NL:PASSWORD@HOST.pr.thordata.net:9999 +# THORDATA_ROUTE_JP=http://td-customer-USERNAME-country-JP:PASSWORD@HOST.pr.thordata.net:9999 diff --git a/.gitignore b/.gitignore index 73825c1..9132433 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ gfpgan/ # Local evaluation data .local-eval/ +/.oracle-slots.json # Other harnesses' generated config. Claude's sync owns these; a committed copy # is a second source nobody reconciles. Anchored so a same-named source file diff --git a/docs/index.md b/docs/index.md index ba67e6a..962784d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,6 +48,7 @@ The current behavior is defined by the code, tests, README, and user guides. - [SynthID identity follow-up](synthid-robust-identity-research-2026-06-08.md) - [Video SynthID quality research](video-synthid-quality-research.md) - [OpenAI SynthID oracle ladders](synthid-oracle-ladders.md) (routing hub) +- [Provider oracle tooling](provider-oracles.md) (Google, OpenAI, Microsoft, and Meta execution surfaces) - [SynthID detector and removal plan](synthid-detector-removal-plan.md) (chronological mixed archive) - [Text protection research](text-protection-research.md) - [Audio provenance experiment](audio-provenance-experiment.md) (local AudioSeal matched-oracle study of video audio tracks across visual cleaning) diff --git a/docs/module-internals.md b/docs/module-internals.md index 80c1ae4..541e64b 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -949,10 +949,12 @@ multipart file to `content_provenance_checks.create` and parses exactly one used as fallback evidence. Missing, duplicate, or unknown SynthID outcomes are errors rather than negative detections. -The default SDK client has a 120-second request timeout and zero automatic -retries. One upload acknowledgement therefore authorizes at most one media -transmission rather than inheriting the SDK's retry default. Request logs keep -the endpoint, temporary basename, media type, byte count, timeout, retry policy, +The default SDK client has a 120-second request timeout, zero automatic retries, +and an HTTP transport with `trust_env=False`, so process proxy variables cannot +reroute it. A caller may provide one explicit API key without mutating the process +environment. One upload acknowledgement therefore authorizes at most one media +transmission rather than inheriting the SDK's retry default. Request logs keep the +endpoint, temporary basename, media type, byte count, timeout, retry policy, duration, HTTP status, error code, and request id when available, but omit the source path, image bytes, credentials, and decoded-pixel fingerprint. diff --git a/docs/provider-oracles.md b/docs/provider-oracles.md new file mode 100644 index 0000000..c76942a --- /dev/null +++ b/docs/provider-oracles.md @@ -0,0 +1,367 @@ +# Provider oracle tooling + +`scripts/provider_oracles.py` is the development-only entry point for the four +remote watermark-oracle families used by this project. It drives Google through +the user's real authenticated Chrome and drives the other public Web surfaces +through isolated Playwright. It keeps provider scope, execution identity, +immutable media, and verbatim results in one format without exposing any oracle +through the installed CLI or public Python API. + +## Surfaces + +| Surface key | Provider signal | Access | Project evidence | +| --- | --- | --- | --- | +| `gemini-web` | Google SynthID | Signed-in Gemini web account | Images and videos | +| `openai-api` | OpenAI SynthID | Official Content Provenance API | Images | +| `openai-web` | OpenAI SynthID | Public web verifier | Images | +| `microsoft-api` | Microsoft InvisMark | Azure Content Provenance Detection API | Images | +| `microsoft-web` | Microsoft InvisMark | Public Azure AI validation page | Images | +| `meta-web` | Meta Content Seal | Anonymous Meta identification page | Images | + +The old Google SynthID Detector portal does not work. On 2026-09-07, the +official `labs.google/synthid` entry point redirected a signed-in browser to a +Google 404. Historical corpus rows may still name `synthid-portal`; that value +describes how an old result was obtained and is not an operational surface. +Adobe Inspect and local C2PA, DWT-DCT, TrustMark, and research detectors are +useful evidence readers, not interchangeable provider pixel-watermark oracles. + +The surface capability and this project's evidence coverage are separate. The +current Google, Microsoft, and Meta pages accept image, video, and audio inputs; +the OpenAI API accepts image and audio. A media type does not become certified +merely because a surface accepts it. Run `list --json` to read both fields. + +## Execution slots + +Keep account, browser-profile, and network labels in the gitignored repository-local +`.oracle-slots.json`. Labels and proxy environment-variable names are provenance, +not credentials. Do not commit this file or put cookies, tokens, passwords, proxy +URLs, or account secrets in it. When `--slot` is present, the CLI reads this path by +default; `--slots PATH` explicitly overrides it. + +```json +{ + "format_version": 1, + "slots": [ + { + "name": "gemini-personal", + "surface": "gemini-web", + "account_label": "google-account-a", + "browser_profile": "Your Chrome", + "google_account_index": 0, + "network_label": "home" + }, + { + "name": "gemini-research", + "surface": "gemini-web", + "account_label": "google-account-b", + "browser_profile": "Your Chrome", + "google_account_index": 2, + "network_label": "office" + }, + { + "name": "openai-web-thordata-us", + "surface": "openai-web", + "account_label": null, + "browser_profile": null, + "network_label": "thordata-us-residential", + "proxy_url_env": "THORDATA_ROUTE_US" + }, + { + "name": "microsoft-web-thordata-de", + "surface": "microsoft-web", + "account_label": null, + "browser_profile": null, + "network_label": "thordata-de-residential", + "proxy_url_env": "THORDATA_ROUTE_DE" + }, + { + "name": "meta-web-thordata-us", + "surface": "meta-web", + "account_label": null, + "browser_profile": null, + "network_label": "thordata-us-residential", + "proxy_url_env": "THORDATA_ROUTE_US" + }, + { + "name": "openai-web-thordata-de", + "surface": "openai-web", + "account_label": null, + "browser_profile": null, + "network_label": "thordata-de-residential", + "proxy_url_env": "THORDATA_ROUTE_DE" + }, + { + "name": "openai-api-1", + "surface": "openai-api", + "account_label": "project-1", + "browser_profile": null, + "network_label": "home", + "api_key_env": "OPENAI_API_KEY_1" + }, + { + "name": "openai-api-2", + "surface": "openai-api", + "account_label": "project-2", + "browser_profile": null, + "network_label": "home", + "api_key_env": "OPENAI_API_KEY_2" + }, + { + "name": "openai-api-3", + "surface": "openai-api", + "account_label": "project-3", + "browser_profile": null, + "network_label": "home", + "api_key_env": "OPENAI_API_KEY_3" + }, + { + "name": "microsoft-api-1", + "surface": "microsoft-api", + "account_label": "content-safety-project", + "browser_profile": null, + "network_label": "home" + } + ] +} +``` + +A routed batch selects one slot explicitly. A Gemini slot must name the Google +account, its real Chrome profile, and its nonnegative `/u/N/` account index; an +API slot must name its account or project and cannot name a browser profile. The +tool records the network route that the operator has already established. +Non-Google Web slots may name `proxy_url_env`; Playwright reads the secret proxy +URL from that environment variable without writing or logging it. The tool does +not rotate accounts or networks, retry through another IP after throttling, or +merge sessions. Record a throttled response as `refused`; choosing another +authorized slot is a separate operator decision and a separate batch. This +preserves per-account and per-IP history without turning provider limits into an +automatic evasion mechanism. + +Each `openai-api` slot must name exactly one `api_key_env`. Put the corresponding +values in the gitignored local `.env`, never in the slot JSON: + +```dotenv +OPENAI_API_KEY_1=replace-with-first-key +OPENAI_API_KEY_2=replace-with-second-key +OPENAI_API_KEY_3=replace-with-third-key +THORDATA_ROUTE_US=http://td-customer-USERNAME-country-US:PASSWORD@HOST.pr.thordata.net:9999 +THORDATA_ROUTE_DE=http://td-customer-USERNAME-country-DE:PASSWORD@HOST.pr.thordata.net:9999 +THORDATA_ROUTE_GB=http://td-customer-USERNAME-country-GB:PASSWORD@HOST.pr.thordata.net:9999 +THORDATA_ROUTE_NL=http://td-customer-USERNAME-country-NL:PASSWORD@HOST.pr.thordata.net:9999 +THORDATA_ROUTE_JP=http://td-customer-USERNAME-country-JP:PASSWORD@HOST.pr.thordata.net:9999 +``` + +The tool reads only the variable selected by the requested slot. A nonempty +process environment value overrides the matching `.env` entry. It never prints +the value or falls through to another token after authentication, entitlement, +quota, or transport failure. The no-slot compatibility path reads +`OPENAI_API_KEY`. + +Several Google accounts may share one real Chrome profile: give them distinct +slot names and `google_account_index` values while keeping `browser_profile` the +same. + +The ThorData examples use its [residential proxy country parameter](https://doc.thordata.com/doc/proxies/residential-proxies/location-settings/country-region). +The proxy username encodes one explicit two-letter ISO country code. Route +selection remains explicit and never changes after a refusal. OpenAI, +Microsoft, and Meta Web slots may reuse any route variable; the surface remains +part of each distinct slot. OpenAI API slots never accept `proxy_url_env` and +use the current direct network. + +Validate and inspect the slots: + +```bash +uv run python scripts/provider_oracles.py slots .oracle-slots.json +uv run python scripts/provider_oracles.py guide gemini-web --slot gemini-personal +``` + +## Immutable manual batches + +Prepare a batch outside the repository: + +```bash +uv run python scripts/provider_oracles.py prepare gemini-web input.png \ + --output /tmp/gemini-check-001 \ + --slot gemini-personal +``` + +For PNG, JPEG, and WebP, preparation strips AI provenance metadata and proves +that the decoded RGBA pixels and format did not change. This prevents a C2PA +result from masquerading as a pixel-watermark result. Video and audio are copied +byte for byte; the manifest explicitly records that their metadata was not +stripped. Use the matched-control protocols in [`synthid.md`](synthid.md) for +video claims. + +The batch contains: + +- `manifest.json`, immutable surface, slot, source, upload, and hash identities; +- `manifest.sha256`, the manifest digest; +- `uploads/`, sanitized images or byte-identical non-image media; +- `results.json`, the only mutable document. + +Submit each upload in index order, one file per fresh provider result. Preserve +the settled response verbatim, including an unclear or refused response. Record +watermark and provenance separately: + +```bash +uv run python scripts/provider_oracles.py record /tmp/gemini-check-001/manifest.json \ + --artifact-id 000-0123456789abcdef \ + --watermark-result detected \ + --provenance-result absent \ + --raw-response "SynthID watermark detected" \ + --checked-at 2026-09-07T18:30:00Z + +uv run python scripts/provider_oracles.py verify \ + /tmp/gemini-check-001/manifest.json --require-complete +``` + +The verifier rereads every source and upload hash, binds result rows to exact +upload bytes, checks timezone-aware timestamps, and never interprets +`indeterminate` or `refused` as `not_detected`. + +## Web automation + +Install Playwright's isolated Chromium once after the development dependencies: + +```bash +uv sync --extra dev +uv run playwright install chromium +``` + +Google is intentionally excluded from the standalone runner because its oracle +requires the user's existing authenticated Chrome session. Invoke the project +`provider-oracles` skill to prepare the batch, select the named real Chrome +profile and `/u/N/` Google account, upload each prepared artifact, capture the +settled SynthID tool outcome, and record it. + +OpenAI Web, Microsoft Web, and Meta run entirely through a fresh isolated +Playwright browser. A direct-route example: + +```bash +uv run python scripts/provider_oracles.py prepare meta-web input.png \ + --output /tmp/meta-check-001 \ + --slot meta-direct + +uv run python scripts/provider_oracles.py run-web \ + /tmp/meta-check-001/manifest.json --acknowledge-uploads +``` + +For a ThorData-routed OpenAI, Microsoft, or Meta Web slot, keep the generated +proxy URLs in the gitignored `.env`, then select one route explicitly: + +```bash +uv run python scripts/provider_oracles.py run-web \ + /tmp/openai-check-001/manifest.json --acknowledge-uploads \ + --env-file .env +``` + +The runner reuses one context and one route for the batch, performs a fresh page +navigation before each upload, does not retry, and stores the settled page text. +For a selected proxy route it enables certificate handling in that isolated +Chromium launch and context; direct launches keep Playwright's normal TLS +verification. A 2026-09-07 preflight sent one read-only request through each +configured route and observed five distinct egress addresses with the requested +US, DE, GB, NL, and JP country codes. A deliberately invalid country code failed +instead of silently using a random route. The addresses are intentionally not +recorded because residential egress is ephemeral; this proves route selection, +not reachability of any provider page. One observation per surface is not a +reliability baseline. +It is headed by default: a live 2026-09-07 probe reached OpenAI's upload control +that way while the same isolated Chromium received a Cloudflare gate in headless +mode. Use `--headless` only for a surface already verified in that mode. Bot or +quota refusal is `refused`, a transport failure is `unreachable`, and unknown or +changed page wording is `indeterminate`. + +Each provider mounts its file input before the upload handler is necessarily +ready. Microsoft and Meta reach a usable handler at network idle. OpenAI keeps +background requests alive and never reaches network idle, so its observable +readiness seam is the page `load` state instead. The adapter waits for the +surface-specific state before assigning the file. OpenAI's static FAQ contains +the lowercase phrase `generated with OpenAI tools`; only the exact title-cased +result label may settle a positive result. Its generic `Something went wrong` +response settles as `indeterminate`, not clean. Meta's live positive wording +changed from `AI signatures from Meta were found` to `AI signatures were found` +by 2026-09-07; both exact forms remain accepted. + +## OpenAI API + +The one automated path calls the hardened metadata-independent adapter already +covered by `tests/test_openai_provenance.py`: + +```bash +uv run python scripts/provider_oracles.py check-openai input.png \ + --acknowledge-upload \ + --slot openai-api-1 \ + --env-file .env +``` + +The command accepts one image and one explicit acknowledgement, makes at most +one request with SDK retries disabled, and emits the selected slot beside the +result. Its SDK transport sets `trust_env=False`, so `HTTP_PROXY`, +`HTTPS_PROXY`, and related process settings cannot silently proxy an API slot. +On 2026-09-07, a no-upload `models.list()` authentication probe succeeded for +all three configured keys over that direct transport. The model counts are not +recorded because they are operationally volatile. The official response keeps +SynthID separate from C2PA. The current adapter deliberately handles images +only even though the current API surface also accepts audio. + +OpenAI documents `not_detected` as absence of a supported signal, not proof that +content was not AI-generated. The API must not become an adaptive removal loss +or automatic search loop. See [`synthid.md`](synthid.md) for the oracle boundary. + +## Microsoft API + +The direct Microsoft path uses the Azure Content Provenance Detection preview +API. Run `az login` first, then keep the non-secret resource configuration in +the gitignored local `.env`. The script reads both resource keys into memory +through Azure CLI and never writes or logs them: + +```dotenv +AZURE_CONTENT_SAFETY_ENDPOINT=https://your-resource.cognitiveservices.azure.com/ +AZURE_CONTENT_SAFETY_SUBSCRIPTION_ID=your-subscription-id +AZURE_CONTENT_SAFETY_RESOURCE_GROUP=your-content-safety-resource-group +AZURE_CONTENT_SAFETY_ACCOUNT_NAME=your-content-safety-account +AZURE_ORACLE_STORAGE_RESOURCE_GROUP=your-storage-resource-group +``` + +The live preview API accepts only an Azure Blob Storage HTTPS endpoint, despite +the how-to page also describing a general public URL. Give the Content Safety +resource's system-assigned identity `Storage Blob Data Reader` on the private +input storage account. The command downloads that private blob through Azure +CLI and refuses to submit unless its SHA-256 equals the named local file: + +```bash +uv run python scripts/provider_oracles.py check-microsoft input.png \ + --content-uri https://storage-account.blob.core.windows.net/oracle-inputs/input.png \ + --acknowledge-upload \ + --slot microsoft-api-1 \ + --env-file .env +``` + +The adapter sends one detection request with no submit retry, polls only that +operation, ignores process proxy settings, and records Watermark separately +from C2PA. The current adapter accepts JPEG, PNG, and WebP up to the provider's +100 MiB limit. See Microsoft's +[Content Provenance workflow](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection). + +## Live cross-provider smoke + +On 2026-09-08, the public `data/contentseal/originals/gen_fox_forest.webp` +fixture, or a metadata-stripped pixel-identical copy of it, was submitted once +per configured account or surface. These are transport and parser smokes, not +error rate estimates: + +| Surface | Route and repetitions | Recorded result | +| --- | --- | --- | +| Gemini Web | User's real Chrome, three authenticated account slots | 3/3 `not_detected`; Gemini reported no reliable Google AI signal. | +| OpenAI API | Three named API-key slots, direct transport | 3/3 SynthID `not_detected`; C2PA `not_present`; all responses were HTTP 200. | +| OpenAI Web | ThorData GB, isolated Playwright | `indeterminate`; after accepting the prepared upload, the provider returned `Something went wrong. Please try again.` | +| Microsoft API | Direct API, private hash-matched Azure blob | HTTP 202, operation `Succeeded`; Watermark `not_detected`, C2PA `absent`, provider outcome `NoProvenanceDetected`. | +| Microsoft Web | ThorData US, isolated Playwright | `not_detected`; the page's exact settled label was `Inconclusive`. | +| Meta Web | ThorData DE, isolated Playwright | `detected`; attribution `Muse Image 1 - Meta` and the existing fixture generation ID were preserved. | + +The same setup verified distinct US, DE, GB, NL, and JP egress addresses whose +reported countries matched their requested routes. A deliberately invalid +country code failed rather than falling back to a random location. The Google +404 and the OpenAI Web error remain availability findings; neither is evidence +that a watermark is absent. diff --git a/docs/synthid-detector-removal-plan.md b/docs/synthid-detector-removal-plan.md index 5c049d8..21b0252 100644 --- a/docs/synthid-detector-removal-plan.md +++ b/docs/synthid-detector-removal-plan.md @@ -3660,6 +3660,10 @@ news-publisher corporate email and required News Publisher and Publisher URL fields. The words research, academic and developer do not appear on the form at all. Research access is excluded by construction rather than by refusal. +**2026-09-07 operational update:** the old portal no longer works. Its official +`labs.google/synthid` entry point redirects a signed-in browser to a Google 404; +Gemini remains the Google verifier route. + The Gemini app therefore stays the only working route, at roughly two checks per account per two hours. The Google branch's control shortage has no near-term provider-labelled fix. diff --git a/docs/synthid.md b/docs/synthid.md index 4564b80..c6ac098 100644 --- a/docs/synthid.md +++ b/docs/synthid.md @@ -575,18 +575,20 @@ The SynthID decoder is proprietary and not released: > -- Gowal et al., arXiv:2510.09263 There are no released payload-decoder weights or public algorithm. Google -provides verification in Gemini and a limited SynthID Detector -portal. OpenAI now documents a synchronous Content Provenance API whose image -response contains separate C2PA and SynthID outcomes. That API is a remote, -OpenAI-scoped verifier, not a local decoder. Its documentation also says not to -use repeated queries to reverse-engineer, remove, or evade a watermark, so an -adaptive research loop requires separate authorization. +provides verification in Gemini. The old limited SynthID Detector portal does +not work: on 2026-09-07, its official `labs.google/synthid` entry point +redirected a signed-in browser to a Google 404. OpenAI now documents a +synchronous Content Provenance API whose image response contains separate C2PA +and SynthID outcomes. That API is a remote, OpenAI-scoped verifier, not a local +decoder. Its documentation also says not to use repeated queries to +reverse-engineer, remove, or evade a watermark, so an adaptive research loop +requires separate authorization. -Google's SynthID Detector service is: +Google previously described the SynthID Detector service as: > "a verification portal" in early testing with "journalists and media > professionals" on a waitlist -> -- deepmind.google/models/synthid/ +> -- deepmind.google/models/synthid/ (historical description) The external variant SynthID-O is available "through partnerships" only. This package does not ship a local payload decoder. Research on a periodic lattice diff --git a/docs/verification-plan.md b/docs/verification-plan.md index 107e80c..7605396 100644 --- a/docs/verification-plan.md +++ b/docs/verification-plan.md @@ -252,6 +252,10 @@ report recall from the detector-sampled set. ## Tier D -- external oracles +The unified maintainer workflow, execution-slot schema, immutable batches, and +result recorder are documented in [`provider-oracles.md`](provider-oracles.md) +and implemented by `scripts/provider_oracles.py`. + Proprietary watermark removal cannot be verified locally by design -- no public decoder exists. Each vendor has its own oracle and it covers only that vendor's content: `openai.com/verify` for OpenAI, the Gemini app for Google, and Microsoft's diff --git a/pyproject.toml b/pyproject.toml index 70c9bac..393af81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,6 +184,11 @@ dev = [ # installed CLI, the public Python API, or any runtime pipeline. "audioseal>=0.2.0", "huggingface-hub>=0.20.0", + # Direct Microsoft Content Provenance API transport. The adapter uses an + # Azure CLI Entra token and ignores process proxy settings. + "httpx>=0.28,<1", + # Development-only isolated browser automation for public provider oracles. + "playwright>=1.62,<2", "pytest>=8.0.0", "pytest-cov>=4.1.0", "pytest-xdist>=3.5.0", diff --git a/scripts/README.md b/scripts/README.md index 0c8cc72..72f0e48 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,6 +16,9 @@ explicitly names a tracked canonical result. | `invisible_quality_audit.py` | Pair originals and invisible-removal outputs for quality review. | | `metadata_removal_audit.py` | Check metadata detection/removal parity over a corpus. | | `pill_gate_audit.py` | Measure the Jimeng pill detector on the product path. | +| `provider_oracle_web.py` | Implement isolated Playwright submissions for the unified provider-oracle runner. | +| `provider_oracle_microsoft.py` | Call Microsoft's Content Provenance API with hash-bound public media and Azure CLI authentication. | +| `provider_oracles.py` | Drive and validate hash-bound Google, OpenAI, Microsoft, and Meta oracle checks. | | `real_examples_e2e.py` | Run end-to-end confidence checks over local examples. | | `record_parity_audit.py` | Compare record-based and file-based identification. | | `resource_ceilings.py` | Measure peak RSS and runtime for fill backends. | diff --git a/scripts/provider_oracle_microsoft.py b/scripts/provider_oracle_microsoft.py new file mode 100644 index 0000000..4b9f201 --- /dev/null +++ b/scripts/provider_oracle_microsoft.py @@ -0,0 +1,390 @@ +"""Direct Azure Content Provenance Detection adapter for oracle experiments.""" + +from __future__ import annotations + +import hashlib +import logging +import os +import re +import shutil +import subprocess +import tempfile +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, cast +from urllib.parse import unquote, urlparse + +import httpx + +log = logging.getLogger(__name__) + +API_VERSION = "2026-07-01-preview" +DETECTOR_ID = "azure-content-provenance-2026-07-01-preview" +MAX_MEDIA_BYTES = 100 * 1024 * 1024 +REQUEST_TIMEOUT_SECONDS = 120.0 +POLL_INTERVAL_SECONDS = 5.0 +TERMINAL_OPERATION_STATES = frozenset({"Succeeded", "Failed", "Canceled"}) + +MicrosoftWatermarkStatus = Literal["detected", "not_detected"] +MicrosoftProvenanceStatus = Literal["present", "absent"] + + +@dataclass(frozen=True) +class MicrosoftMarker: + """One marker returned by the official Microsoft detector.""" + + type: Literal["Watermark", "C2PA"] + provider: str | None + model_name: str | None + timestamp: str | None + + def to_dict(self) -> dict[str, str | None]: + """Return a JSON-safe marker using the provider's field names.""" + return { + "type": self.type, + "provider": self.provider, + "modelName": self.model_name, + "timestamp": self.timestamp, + } + + +@dataclass(frozen=True) +class MicrosoftProvenanceDetection: + """One official Microsoft watermark and C2PA verdict.""" + + status: MicrosoftWatermarkStatus + provenance_status: MicrosoftProvenanceStatus + operation_id: str + markers: tuple[MicrosoftMarker, ...] + raw_response: dict[str, object] + detector: str = DETECTOR_ID + signal_family: str = "invismark" + provider_scope: str = "microsoft" + backend: str = "official-microsoft-api" + metadata_used_for_verdict: bool = False + + @property + def detected(self) -> bool: + """Whether Microsoft found one of its supported watermark signals.""" + return self.status == "detected" + + def to_dict(self) -> dict[str, object]: + """Return a JSON-safe result while preserving the provider response.""" + return { + "status": self.status, + "provenance_status": self.provenance_status, + "operation_id": self.operation_id, + "markers": [marker.to_dict() for marker in self.markers], + "raw_response": self.raw_response, + "detector": self.detector, + "signal_family": self.signal_family, + "provider_scope": self.provider_scope, + "backend": self.backend, + "metadata_used_for_verdict": self.metadata_used_for_verdict, + } + + +def _required_string(mapping: Mapping[str, Any], field: str, *, context: str) -> str: + value = mapping.get(field) + if not isinstance(value, str) or not value: + raise RuntimeError(f"{context} has an invalid {field!r} field") + return value + + +def _optional_string(mapping: Mapping[str, Any], field: str, *, context: str) -> str | None: + value = mapping.get(field) + if value is None or isinstance(value, str): + return value + raise RuntimeError(f"{context} has an invalid {field!r} field") + + +def _json_mapping(response: httpx.Response, *, context: str) -> dict[str, object]: + try: + payload: object = response.json() + except ValueError as error: + raise RuntimeError(f"{context} returned invalid JSON") from error + if not isinstance(payload, dict): + raise RuntimeError(f"{context} returned a non-object JSON payload") + return cast("dict[str, object]", payload) + + +def _parse_marker(raw_marker: object) -> MicrosoftMarker: + if not isinstance(raw_marker, Mapping): + raise RuntimeError("Microsoft Content Provenance returned an invalid marker") + marker = cast("Mapping[str, Any]", raw_marker) + marker_type = marker.get("type") + if marker_type not in {"Watermark", "C2PA"}: + raise RuntimeError(f"Microsoft Content Provenance returned an unsupported marker type: {marker_type!r}") + return MicrosoftMarker( + type=cast("Literal['Watermark', 'C2PA']", marker_type), + provider=_optional_string(marker, "provider", context="Microsoft marker"), + model_name=_optional_string(marker, "modelName", context="Microsoft marker"), + timestamp=_optional_string(marker, "timestamp", context="Microsoft marker"), + ) + + +def parse_detection(payload: Mapping[str, Any]) -> MicrosoftProvenanceDetection: + """Parse a terminal Microsoft operation without conflating C2PA and watermark.""" + operation_id = _required_string(payload, "id", context="Microsoft Content Provenance response") + if payload.get("status") != "Succeeded": + raise RuntimeError(f"Microsoft Content Provenance operation did not succeed: {payload.get('status')!r}") + raw_result = payload.get("result") + if not isinstance(raw_result, Mapping): + raise RuntimeError("Microsoft Content Provenance response has no result object") + result = cast("Mapping[str, Any]", raw_result) + outcome = result.get("outcome") + if outcome == "NoProvenanceDetected": + markers: tuple[MicrosoftMarker, ...] = () + elif outcome == "ProvenanceDetected": + raw_markers = result.get("results") + if not isinstance(raw_markers, list) or not raw_markers: + raise RuntimeError("Microsoft Content Provenance reported provenance without markers") + markers = tuple(_parse_marker(marker) for marker in cast("list[object]", raw_markers)) + else: + raise RuntimeError(f"Microsoft Content Provenance returned an unsupported outcome: {outcome!r}") + return MicrosoftProvenanceDetection( + status="detected" if any(marker.type == "Watermark" for marker in markers) else "not_detected", + provenance_status="present" if any(marker.type == "C2PA" for marker in markers) else "absent", + operation_id=operation_id, + markers=markers, + raw_response=dict(cast("Mapping[str, object]", payload)), + ) + + +def _azure_cli_value(arguments: list[str], *, value_label: str) -> str: + """Read one secret from Azure CLI without logging it.""" + executable = shutil.which("az") + if executable is None: + raise RuntimeError("Microsoft API verification needs Azure CLI; install it and run `az login`") + completed = subprocess.run( # noqa: S603 + [executable, *arguments], check=False, capture_output=True, text=True + ) + value = completed.stdout.strip() + if completed.returncode != 0 or not value: + detail = completed.stderr.strip() or f"Azure CLI returned no {value_label}" + raise RuntimeError(f"could not obtain {value_label}: {detail}") + return value + + +def azure_content_safety_key(subscription_id: str, resource_group: str, account_name: str) -> str: + """Read the Content Safety resource key into memory through Azure CLI.""" + return _azure_cli_value( + [ + "cognitiveservices", + "account", + "keys", + "list", + "--name", + account_name, + "--resource-group", + resource_group, + "--subscription", + subscription_id, + "--query", + "key1", + "--output", + "tsv", + ], + value_label="Azure Content Safety resource key", + ) + + +def _validate_https_url(value: str, *, label: str) -> str: + parsed = urlparse(value) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise ValueError(f"{label} must be an HTTPS URL without credentials, query, or fragment") + return value + + +def _azure_blob_parts(content_uri: str) -> tuple[str, str, str]: + """Parse the exact private Azure Blob URL accepted by the live API.""" + parsed = urlparse(_validate_https_url(content_uri, label="content_uri")) + hostname = parsed.hostname or "" + match = re.fullmatch(r"([a-z0-9]{3,24})\.blob\.core\.windows\.net", hostname) + path_parts = parsed.path.removeprefix("/").split("/", maxsplit=1) + if match is None or len(path_parts) != 2 or not all(path_parts): + raise ValueError("content_uri must identify one blob on an Azure Blob Storage HTTPS endpoint") + return match.group(1), unquote(path_parts[0]), unquote(path_parts[1]) + + +def _local_sha256(path: Path) -> str: + """Hash a local file without loading the complete media into memory.""" + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def azure_blob_sha256(subscription_id: str, storage_resource_group: str, content_uri: str) -> str: + """Download one private Azure blob through CLI and hash its exact bytes.""" + account_name, container_name, blob_name = _azure_blob_parts(content_uri) + storage_key = _azure_cli_value( + [ + "storage", + "account", + "keys", + "list", + "--account-name", + account_name, + "--resource-group", + storage_resource_group, + "--subscription", + subscription_id, + "--query", + "[0].value", + "--output", + "tsv", + ], + value_label="Azure Storage resource key", + ) + executable = shutil.which("az") + if executable is None: + raise RuntimeError("Microsoft API verification needs Azure CLI; install it and run `az login`") + with tempfile.TemporaryDirectory(prefix="microsoft-oracle-blob-") as directory: + download = Path(directory) / "content" + command = [ + executable, + "storage", + "blob", + "download", + "--account-name", + account_name, + "--container-name", + container_name, + "--name", + blob_name, + "--file", + str(download), + "--auth-mode", + "key", + "--subscription", + subscription_id, + "--no-progress", + "--only-show-errors", + "--output", + "none", + ] + child_environment = os.environ.copy() + child_environment["AZURE_STORAGE_KEY"] = storage_key + completed = subprocess.run( # noqa: S603 + command, check=False, capture_output=True, text=True, env=child_environment + ) + if completed.returncode != 0 or not download.is_file(): + detail = completed.stderr.strip() or "Azure CLI did not download the blob" + raise RuntimeError(f"could not read the Microsoft oracle blob: {detail}") + if download.stat().st_size > MAX_MEDIA_BYTES: + raise ValueError("Microsoft Content Provenance media exceeds the 100 MiB limit") + digest = _local_sha256(download) + log.info("Microsoft private blob preflight: uri=%s sha256=%s", content_uri, digest) + return digest + + +def _poll_url(endpoint: str, operation_id: str) -> str: + if re.fullmatch(r"[0-9a-fA-F-]{36}", operation_id) is None: + raise RuntimeError("Microsoft Content Provenance returned an invalid operation id") + return f"{endpoint.rstrip('/')}/contentsafety/provenance/operations/{operation_id}?api-version={API_VERSION}" + + +def verify_microsoft_provenance( + source: Path, + *, + content_uri: str, + endpoint: str, + subscription_id: str, + resource_group: str, + account_name: str, + storage_resource_group: str, + acknowledge_upload: bool = False, + client: httpx.Client | None = None, + key_loader: Callable[[str, str, str], str] = azure_content_safety_key, + blob_hash_loader: Callable[[str, str, str], str] = azure_blob_sha256, + sleeper: Callable[[float], None] = time.sleep, + monotonic: Callable[[], float] = time.monotonic, +) -> MicrosoftProvenanceDetection: + """Run one hash-bound Microsoft provenance operation with no submit retry.""" + if not acknowledge_upload: + raise ValueError("Microsoft verification requires acknowledge_upload=True") + if not source.is_file(): + raise ValueError(f"Microsoft verification source is not a file: {source}") + if source.suffix.lower() not in {".jpeg", ".jpg", ".png", ".webp"}: + raise ValueError("the current Microsoft API adapter supports JPEG, PNG, and WEBP images") + content_uri = _validate_https_url(content_uri, label="content_uri") + _azure_blob_parts(content_uri) + endpoint = _validate_https_url(endpoint, label="endpoint").rstrip("/") + configuration = { + "subscription_id": subscription_id, + "resource_group": resource_group, + "account_name": account_name, + "storage_resource_group": storage_resource_group, + } + for label, value in configuration.items(): + if not value.strip(): + raise ValueError(f"{label} must not be empty") + + if blob_hash_loader(subscription_id, storage_resource_group, content_uri) != _local_sha256(source): + raise ValueError("content_uri bytes do not match the local source") + resource_key = key_loader(subscription_id, resource_group, account_name) + request_headers = {"Ocp-Apim-Subscription-Key": resource_key} + owns_client = client is None + if client is None: + client = httpx.Client(timeout=REQUEST_TIMEOUT_SECONDS, follow_redirects=True, trust_env=False) + try: + submit_url = f"{endpoint}/contentsafety/provenance:detect?api-version={API_VERSION}" + request_body = {"content": {"uri": content_uri}} + log.info( + "Microsoft Content Provenance request: POST %s headers={'Ocp-Apim-Subscription-Key': ''} body=%s", + submit_url, + request_body, + ) + submit = client.post(submit_url, headers=request_headers, json=request_body) + log.info( + "Microsoft Content Provenance response: status=%s headers=%s body=%s", + submit.status_code, + dict(submit.headers), + submit.text, + ) + if submit.status_code != 202: + raise RuntimeError(f"Microsoft Content Provenance submit returned HTTP {submit.status_code}: {submit.text}") + operation_id = _required_string( + _json_mapping(submit, context="Microsoft Content Provenance submit"), + "id", + context="Microsoft Content Provenance submit", + ) + poll_url = _poll_url(endpoint, operation_id) + deadline = monotonic() + REQUEST_TIMEOUT_SECONDS + while True: + log.info( + "Microsoft Content Provenance request: GET %s headers={'Ocp-Apim-Subscription-Key': ''}", + poll_url, + ) + poll = client.get(poll_url, headers=request_headers) + log.info( + "Microsoft Content Provenance response: status=%s headers=%s body=%s", + poll.status_code, + dict(poll.headers), + poll.text, + ) + if poll.status_code != 200: + raise RuntimeError(f"Microsoft Content Provenance poll returned HTTP {poll.status_code}: {poll.text}") + payload = _json_mapping(poll, context="Microsoft Content Provenance poll") + state = payload.get("status") + if state in TERMINAL_OPERATION_STATES: + return parse_detection(payload) + if state not in {"NotStarted", "Running"}: + raise RuntimeError(f"Microsoft Content Provenance returned an unsupported status: {state!r}") + if monotonic() >= deadline: + raise RuntimeError("Microsoft Content Provenance operation timed out") + sleeper(POLL_INTERVAL_SECONDS) + finally: + if owns_client: + client.close() diff --git a/scripts/provider_oracle_web.py b/scripts/provider_oracle_web.py new file mode 100644 index 0000000..6155253 --- /dev/null +++ b/scripts/provider_oracle_web.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Drive public provider-oracle pages in one isolated Playwright context.""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from datetime import UTC, datetime +from time import monotonic +from typing import TYPE_CHECKING, Literal +from urllib.parse import unquote, urlsplit + +import provider_oracles as oracles + +if TYPE_CHECKING: + from collections.abc import Mapping + from pathlib import Path + + from playwright.sync_api import Browser, BrowserContext, BrowserType, Page, ProxySettings + +log = logging.getLogger(__name__) + +PLAYWRIGHT_SURFACES = frozenset({"openai-web", "microsoft-web", "meta-web"}) + + +@dataclass(frozen=True) +class WebVerdict: + """One settled Web result or a distinct could-not-ask outcome.""" + + watermark_result: Literal["detected", "not_detected", "indeterminate", "refused", "unreachable"] + provenance_result: Literal["present", "absent", "indeterminate", "unavailable"] + raw_response: str + + +_RESULT_MARKERS = { + "openai-web": ( + "Generated with OpenAI tools", + "No OpenAI signals detected", + ), + "microsoft-web": ( + "Microsoft AI detected", + "Inconclusive", + "No provenance detected", + ), + "meta-web": ( + "AI signature from Meta was found", + "AI signatures from Meta were found", + "AI signatures were found", + "No AI signatures from Meta were found", + "No AI signatures were found", + ), +} + +_REFUSAL_MARKERS = ( + "you've reached the daily limit", + "you\u2019ve reached the daily limit", + "rate_limited", + "too many requests", + "verify you are human", + "just a moment...", + "access denied", +) + +_INDETERMINATE_MARKERS = ("Something went wrong. Please try again. If the issue persists, contact support.",) + + +def _contains_any(text: str, markers: tuple[str, ...]) -> bool: + lowered = text.casefold() + return any(marker.casefold() in lowered for marker in markers) + + +def _provenance_result(text: str) -> Literal["present", "absent", "unavailable"]: + lowered = text.casefold() + absent = ( + "content credentials not detected", + "no content credentials detected", + "c2pa not detected", + "no c2pa detected", + ) + present = ("content credentials detected", "c2pa detected") + if any(marker in lowered for marker in absent): + return "absent" + if any(marker in lowered for marker in present): + return "present" + return "unavailable" + + +def parse_provider_result(surface: str, page_text: str) -> WebVerdict: + """Map settled provider wording without collapsing refusal or uncertainty.""" + if surface not in PLAYWRIGHT_SURFACES: + raise ValueError(f"unsupported Playwright oracle surface: {surface}") + response = page_text.strip() + lowered = response.casefold() + provenance = _provenance_result(response) + if _contains_any(response, _REFUSAL_MARKERS): + return WebVerdict("refused", provenance, response or "Provider refused the browser request") + if surface == "openai-web": + # The landing-page FAQ repeats the lowercase phrase "generated with + # OpenAI tools" before any upload. The result card uses these exact, + # title-cased labels, so keep this seam case-sensitive. + if "No OpenAI signals detected" in response: + return WebVerdict("not_detected", provenance, response) + if "Generated with OpenAI tools" in response: + return WebVerdict("detected", provenance, response) + elif surface == "microsoft-web": + if "no watermark detected" in lowered or "watermark not detected" in lowered: + return WebVerdict("not_detected", provenance, response) + if "microsoft ai detected" in lowered or "watermark detected" in lowered: + return WebVerdict("detected", provenance, response) + if "inconclusive" in lowered or "no provenance detected" in lowered: + return WebVerdict("not_detected", provenance, response) + else: + if "no ai signatures from meta were found" in lowered or "no ai signatures were found" in lowered: + return WebVerdict("not_detected", "unavailable", response) + if ( + "ai signature from meta was found" in lowered + or "ai signatures from meta were found" in lowered + or "ai signatures were found" in lowered + ): + return WebVerdict("detected", "unavailable", response) + return WebVerdict("indeterminate", provenance, response or "Provider page returned no readable result") + + +def proxy_settings( + slot: oracles.ExecutionSlot, + environ: Mapping[str, str] = os.environ, +) -> ProxySettings | None: + """Resolve a secret proxy URL without putting it in a manifest or log.""" + variable = slot.get("proxy_url_env") + if variable is None: + return None + value = environ.get(variable) + if not value: + raise ValueError(f"proxy environment variable {variable} is not set") + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https", "socks5"} or parsed.hostname is None: + raise ValueError(f"proxy environment variable {variable} is not a valid HTTP, HTTPS, or SOCKS5 URL") + host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname + server = f"{parsed.scheme}://{host}" + if parsed.port is not None: + server = f"{server}:{parsed.port}" + settings: ProxySettings = {"server": server} + if parsed.username is not None: + settings["username"] = unquote(parsed.username) + if parsed.password is not None: + settings["password"] = unquote(parsed.password) + return settings + + +def _new_context(browser: Browser, proxy: ProxySettings | None) -> BrowserContext: + """Create one isolated route, accepting the proxy service's TLS certificate.""" + if proxy is None: + return browser.new_context() + return browser.new_context(proxy=proxy, ignore_https_errors=True) + + +def _launch_browser(chromium: BrowserType, proxy: ProxySettings | None, *, headed: bool) -> Browser: + """Launch Chromium with proxy certificate handling scoped to routed runs.""" + if proxy is None: + return chromium.launch(headless=not headed) + return chromium.launch(headless=not headed, args=["--ignore-certificate-errors"]) + + +def _page_evidence(page: Page) -> str: + """Capture enough provider state to distinguish a response from transport failure.""" + body = page.locator("body").inner_text(timeout=5_000).strip() + if body: + return body + return f"URL: {page.url}\nTitle: {page.title()}".strip() + + +def _settled(surface: str, text: str) -> bool: + if _contains_any(text, _REFUSAL_MARKERS): + return True + if _contains_any(text, _INDETERMINATE_MARKERS): + return True + if surface == "openai-web": + return any(marker in text for marker in _RESULT_MARKERS[surface]) + if not _contains_any(text, _RESULT_MARKERS[surface]): + return False + return surface != "meta-web" or "upload another file" in text.casefold() + + +def _set_upload_file(page: Page, surface: str, upload: Path, timeout_seconds: float) -> None: + """Wait for a provider's upload handler before assigning one prepared file.""" + timeout_ms = int(timeout_seconds * 1_000) + if surface == "openai-web": + # OpenAI keeps background requests alive, so network-idle is + # unreachable. Its visible input becomes handler-ready at page load. + page.wait_for_load_state("load", timeout=timeout_ms) + elif surface in {"meta-web", "microsoft-web"}: + # These pages mount hidden inputs before their selection handlers are + # active. Network-idle is the observable handler-readiness seam. + page.wait_for_load_state("networkidle", timeout=timeout_ms) + file_input = page.locator('input[type="file"]').first + file_input.wait_for(state="attached", timeout=timeout_ms) + file_input.set_input_files(str(upload), timeout=timeout_ms) + + +def _check_page(page: Page, surface: str, upload: Path, timeout_seconds: float) -> WebVerdict: + """Upload one file and wait for one settled, fresh-navigation result.""" + from playwright.sync_api import Error as PlaywrightError + from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + + oracle = oracles.SURFACES[surface] + try: + page.goto(oracle.url, wait_until="domcontentloaded", timeout=int(timeout_seconds * 1_000)) + except PlaywrightError as error: + log.warning("Browser navigation to %s failed: %s", oracle.url, error) + return WebVerdict("unreachable", "unavailable", f"Browser navigation failed: {error}") + + try: + _set_upload_file(page, surface, upload, timeout_seconds) + except PlaywrightTimeoutError: + evidence = _page_evidence(page) + log.warning("Provider upload control did not become ready at %s: %s", oracle.url, evidence) + return parse_provider_result(surface, evidence) + except PlaywrightError as error: + log.warning("Browser upload of %s to %s failed: %s", upload.name, oracle.url, error) + return WebVerdict("unreachable", "unavailable", f"Browser file upload failed: {error}") + + deadline = monotonic() + timeout_seconds + evidence = "" + while monotonic() < deadline: + try: + evidence = _page_evidence(page) + except PlaywrightError: + evidence = "" + if _settled(surface, evidence): + return parse_provider_result(surface, evidence) + page.wait_for_timeout(500) + log.warning("Provider result at %s did not settle within %s seconds: %s", oracle.url, timeout_seconds, evidence) + return parse_provider_result(surface, evidence) + + +def _submit_batch( + surface: str, + uploads: list[Path], + proxy: ProxySettings | None, + *, + headed: bool, + timeout_seconds: float, +) -> list[WebVerdict]: + """Submit every row in one isolated session without retries or route changes.""" + from playwright.sync_api import sync_playwright + + verdicts: list[WebVerdict] = [] + with sync_playwright() as playwright: + browser = _launch_browser(playwright.chromium, proxy, headed=headed) + context = _new_context(browser, proxy) + try: + for upload in uploads: + page = context.new_page() + try: + log.info("Submitting %s to %s", upload.name, oracles.SURFACES[surface].url) + verdict = _check_page(page, surface, upload, timeout_seconds) + log.info("Provider response for %s: %s", upload.name, verdict.raw_response) + verdicts.append(verdict) + finally: + page.close() + finally: + context.close() + browser.close() + return verdicts + + +def run_web_batch( + manifest_path: Path, + *, + acknowledge_uploads: bool, + headed: bool = True, + timeout_seconds: float = 120.0, + env_file: Path | None = None, + environ: Mapping[str, str] = os.environ, +) -> dict[str, object]: + """Run all unrecorded rows through their isolated public Web oracle.""" + if not acknowledge_uploads: + raise ValueError("acknowledge_uploads=True is required before transmitting batch files") + manifest_path = manifest_path.resolve() + oracles.verify_batch(manifest_path) + manifest, results = oracles.load_batch(manifest_path) + surface = manifest["surface"] + if surface == "gemini-web": + raise ValueError("gemini-web requires the real Chrome workflow, not isolated Playwright") + if surface not in PLAYWRIGHT_SURFACES: + raise ValueError(f"surface {surface} is not a Playwright Web oracle") + slot = manifest["slot"] + if slot is None: + raise ValueError("Playwright batches require an explicit execution slot") + secret_values = dict(environ) if env_file is None else oracles.secret_environment(env_file, environ=environ) + proxy = proxy_settings(slot, secret_values) + pending = [ + (manifest_row, result_row) + for manifest_row, result_row in zip(manifest["rows"], results["rows"], strict=True) + if result_row["watermark_result"] is None + ] + if not pending: + return oracles.verify_batch(manifest_path, require_complete=True) + uploads = [manifest_path.parent / row["upload_path"] for row, _result in pending] + verdicts = _submit_batch( + surface, + uploads, + proxy, + headed=headed, + timeout_seconds=timeout_seconds, + ) + if len(verdicts) != len(pending): + raise RuntimeError(f"Playwright returned {len(verdicts)} verdicts for {len(pending)} uploads") + checked_at = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + for (manifest_row, _result), verdict in zip(pending, verdicts, strict=True): + oracles.record_result( + manifest_path, + artifact_id=manifest_row["artifact_id"], + watermark_result=verdict.watermark_result, + provenance_result=verdict.provenance_result, + raw_response=verdict.raw_response, + checked_at=checked_at, + ) + return oracles.verify_batch(manifest_path, require_complete=True) diff --git a/scripts/provider_oracles.py b/scripts/provider_oracles.py new file mode 100644 index 0000000..56f8685 --- /dev/null +++ b/scripts/provider_oracles.py @@ -0,0 +1,1081 @@ +#!/usr/bin/env python3 +"""Prepare, run, record, and verify development-only provider oracle checks. + +The provider services are not interchangeable. This tool gives their manual and +programmatic workflows one local, hash-bound record format without treating one +provider's negative result as evidence about another provider's watermark. + +It never submits a remote request unless ``check-openai``, ``check-microsoft``, +or ``run-web`` is invoked with the explicit upload acknowledgement. Manual batches are written +outside the repository and remain unsubmitted until a maintainer uses the named +provider surface and records its verbatim response. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import re +import shutil +import sys +import tempfile +from collections import Counter +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, cast + +import click +from PIL import Image + +if TYPE_CHECKING: + from collections.abc import Mapping + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SLOTS_PATH = REPOSITORY_ROOT / ".oracle-slots.json" +sys.path.insert(0, str(REPOSITORY_ROOT / "src")) + +log = logging.getLogger(__name__) + +FORMAT_VERSION = 2 +WATERMARK_RESULTS = {"detected", "not_detected", "indeterminate", "refused", "unreachable"} +PROVENANCE_RESULTS = {"present", "absent", "indeterminate", "unavailable"} + +_IMAGE_SUFFIXES = {".jpeg", ".jpg", ".png", ".webp"} +_VIDEO_SUFFIXES = {".mp4"} +_AUDIO_SUFFIXES = {".mp3", ".wav"} + + +@dataclass(frozen=True) +class OracleSurface: + """Stable routing information for one provider-oracle surface.""" + + key: str + provider_key: str + provider: str + signal_family: str + surface: str + automation: Literal["api", "manual", "playwright", "real_browser"] + url: str + media_types: tuple[str, ...] + project_evidence: tuple[str, ...] + prompt: str | None + clean_result_note: str + account_slots: bool + network_slots: bool + + def to_dict(self) -> dict[str, object]: + """Return a JSON-safe catalog row.""" + return asdict(self) + + +class BatchRow(TypedDict): + """One immutable media identity in a prepared batch.""" + + artifact_id: str + index: int + source_path: str + source_sha256: str + upload_path: str + upload_sha256: str + media_type: str + metadata_stripped: bool + pixels_preserved: bool | None + bytes_preserved: bool + pixel_sha256: str | None + width: int | None + height: int | None + format: str + + +class Preparation(TypedDict): + """Integrity facts created while preparing one upload.""" + + metadata_stripped: bool + pixels_preserved: bool | None + bytes_preserved: bool + pixel_sha256: str | None + width: int | None + height: int | None + format: str + + +class ResultRow(TypedDict): + """One mutable external-oracle response bound to an immutable upload.""" + + artifact_id: str + upload_sha256: str + watermark_result: str | None + provenance_result: str | None + raw_response: str | None + checked_at: str | None + + +class ExecutionSlot(TypedDict): + """A non-secret account, browser, and network routing label.""" + + name: str + surface: str + account_label: str | None + browser_profile: str | None + google_account_index: NotRequired[int | None] + network_label: str + proxy_url_env: NotRequired[str | None] + api_key_env: NotRequired[str | None] + + +class ManifestDocument(TypedDict): + """Validated top-level immutable manifest fields used by this tool.""" + + format_version: int + status: str + created_at: str + provider: str + surface: str + oracle: dict[str, object] + slot: ExecutionSlot | None + row_count: int + rows: list[BatchRow] + + +class ResultsDocument(TypedDict): + """Validated top-level mutable result fields used by this tool.""" + + format_version: int + manifest_sha256: str + provider: str + surface: str + rows: list[ResultRow] + + +SURFACES: dict[str, OracleSurface] = { + "gemini-web": OracleSurface( + key="gemini-web", + provider_key="google", + provider="Google", + signal_family="synthid", + surface="Gemini app verification", + automation="real_browser", + url="https://gemini.google.com/", + media_types=("image", "video", "audio"), + project_evidence=("image", "video"), + prompt="Was this image/video/audio created or edited by Google AI?", + clean_result_note="Use not_detected only for an explicit SynthID-negative result; preserve unclear results.", + account_slots=True, + network_slots=True, + ), + "meta-web": OracleSurface( + key="meta-web", + provider_key="meta", + provider="Meta", + signal_family="content_seal", + surface="Meta AI identification", + automation="playwright", + url="https://www.meta.ai/identification/", + media_types=("image", "video", "audio"), + project_evidence=("image",), + prompt=None, + clean_result_note=( + 'Record the settled result after "Upload another file" appears; do not reuse stale page text.' + ), + account_slots=False, + network_slots=True, + ), + "microsoft-api": OracleSurface( + key="microsoft-api", + provider_key="microsoft", + provider="Microsoft", + signal_family="invismark", + surface="Azure Content Provenance Detection API", + automation="api", + url=( + "https://learn.microsoft.com/en-us/rest/api/contentsafety/" + "content-provenance-operations/detect?view=rest-contentsafety-2026-07-01-preview" + ), + media_types=("image", "video", "audio"), + project_evidence=("image",), + prompt=None, + clean_result_note="Record the Watermark result separately from C2PA; the API reads Microsoft signals only.", + account_slots=True, + network_slots=True, + ), + "microsoft-web": OracleSurface( + key="microsoft-web", + provider_key="microsoft", + provider="Microsoft", + signal_family="invismark", + surface="Content Provenance Detection", + automation="playwright", + url="https://ai.azure.com/nextgen/validate", + media_types=("image", "video", "audio"), + project_evidence=("image",), + prompt=None, + clean_result_note=( + "The public surface renders a clean result as Inconclusive. Record Watermark separately from C2PA." + ), + account_slots=False, + network_slots=True, + ), + "openai-api": OracleSurface( + key="openai-api", + provider_key="openai", + provider="OpenAI", + signal_family="synthid", + surface="Content Provenance API", + automation="api", + url="https://developers.openai.com/api/reference/python/resources/content_provenance_checks/methods/create", + media_types=("image", "audio"), + project_evidence=("image",), + prompt=None, + clean_result_note=( + "A not_detected result means no supported OpenAI SynthID was found; it is not a general AI verdict." + ), + account_slots=True, + network_slots=True, + ), + "openai-web": OracleSurface( + key="openai-web", + provider_key="openai", + provider="OpenAI", + signal_family="synthid", + surface="Content Provenance web verifier", + automation="playwright", + url="https://openai.com/verify", + media_types=("image", "audio"), + project_evidence=("image",), + prompt=None, + clean_result_note=( + "Read SynthID separately from Content Credentials and preserve throttled or unclear responses." + ), + account_slots=False, + network_slots=True, + ), +} + +PROVIDER_KEYS = frozenset(surface.provider_key for surface in SURFACES.values()) + + +def _inside(path: Path, parent: Path) -> bool: + """Return whether resolved PATH is inside resolved PARENT.""" + try: + path.resolve().relative_to(parent.resolve()) + except ValueError: + return False + return True + + +def _sha256(path: Path) -> str: + """Hash PATH without loading the complete media file into memory.""" + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _pixel_fingerprint(path: Path) -> tuple[str, int, int, str]: + """Return a bounded-memory RGBA pixel hash, width, height, and format.""" + with Image.open(path) as image: + image.load() + image_format = image.format + if image_format not in {"JPEG", "PNG", "WEBP"}: + raise ValueError(f"unsupported image format: {image_format or 'unknown'}") + digest = hashlib.sha256() + digest.update(f"{image.width}x{image.height}:RGBA\0".encode()) + for top in range(0, image.height, 128): + bottom = min(top + 128, image.height) + digest.update(image.crop((0, top, image.width, bottom)).convert("RGBA").tobytes()) + return digest.hexdigest(), image.width, image.height, image_format + + +def _media_type(path: Path) -> str: + """Classify the conservative media formats used by the project oracles.""" + suffix = path.suffix.lower() + if suffix in _IMAGE_SUFFIXES: + return "image" + if suffix in _VIDEO_SUFFIXES: + return "video" + if suffix in _AUDIO_SUFFIXES: + return "audio" + raise ValueError(f"unsupported oracle media extension: {suffix or ''}") + + +def _aware_timestamp(value: str) -> datetime: + """Parse a timezone-aware ISO-8601 timestamp.""" + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError("checked_at must be a timezone-aware ISO-8601 timestamp") from error + if parsed.tzinfo is None: + raise ValueError("checked_at must be a timezone-aware ISO-8601 timestamp") + return parsed + + +def _utc_now() -> str: + """Return the current UTC timestamp in the manifest's canonical spelling.""" + return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def load_slots(path: Path) -> dict[str, ExecutionSlot]: + """Load non-secret execution slots and reject ambiguous routing metadata.""" + raw_document: object = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw_document, dict): + raise ValueError("slot configuration must be a JSON object") + document = cast("dict[str, object]", raw_document) + if document.get("format_version") != 1: + raise ValueError("unsupported slot configuration format version") + raw_slots = document.get("slots") + if not isinstance(raw_slots, list): + raise ValueError("slot configuration must contain a slots array") + slot_items = cast("list[object]", raw_slots) + allowed_fields = { + "name", + "surface", + "account_label", + "browser_profile", + "google_account_index", + "network_label", + "proxy_url_env", + "api_key_env", + } + slots: dict[str, ExecutionSlot] = {} + for index, raw_slot in enumerate(slot_items): + if not isinstance(raw_slot, dict): + raise ValueError(f"slot {index} must be a JSON object") + slot = cast("dict[str, object]", raw_slot) + unknown = set(slot) - allowed_fields + if unknown: + raise ValueError(f"slot {index} has unsupported fields: {', '.join(sorted(unknown))}") + name = slot.get("name") + surface_key = slot.get("surface") + network_label = slot.get("network_label") + account_label = slot.get("account_label") + browser_profile = slot.get("browser_profile") + google_account_index = slot.get("google_account_index") + proxy_url_env = slot.get("proxy_url_env") + api_key_env = slot.get("api_key_env") + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"slot {index} needs a nonempty name") + if not isinstance(surface_key, str) or surface_key not in SURFACES: + raise ValueError(f"slot {name} names an unknown surface") + if not isinstance(network_label, str) or not network_label.strip(): + raise ValueError(f"slot {name} needs a nonempty network_label") + if account_label is not None and (not isinstance(account_label, str) or not account_label.strip()): + raise ValueError(f"slot {name} has an invalid account_label") + if browser_profile is not None and (not isinstance(browser_profile, str) or not browser_profile.strip()): + raise ValueError(f"slot {name} has an invalid browser_profile") + if google_account_index is not None and ( + isinstance(google_account_index, bool) + or not isinstance(google_account_index, int) + or google_account_index < 0 + ): + raise ValueError(f"slot {name} has an invalid google_account_index") + if proxy_url_env is not None and ( + not isinstance(proxy_url_env, str) or re.fullmatch(r"[A-Z][A-Z0-9_]*", proxy_url_env) is None + ): + raise ValueError(f"slot {name} has an invalid proxy_url_env") + if api_key_env is not None and ( + not isinstance(api_key_env, str) or re.fullmatch(r"[A-Z][A-Z0-9_]*", api_key_env) is None + ): + raise ValueError(f"slot {name} has an invalid api_key_env") + if SURFACES[surface_key].account_slots and account_label is None: + raise ValueError(f"surface {surface_key} requires an account_label") + if account_label is not None and not SURFACES[surface_key].account_slots: + raise ValueError(f"surface {surface_key} does not use account slots") + if surface_key == "gemini-web" and browser_profile is None: + raise ValueError("surface gemini-web requires a browser_profile") + if surface_key == "gemini-web" and google_account_index is None: + raise ValueError("surface gemini-web requires a google_account_index") + if surface_key != "gemini-web" and google_account_index is not None: + raise ValueError("only gemini-web uses google_account_index") + if SURFACES[surface_key].automation == "api" and browser_profile is not None: + raise ValueError(f"API surface {surface_key} does not use a browser_profile") + if surface_key != "gemini-web" and browser_profile is not None: + raise ValueError("only gemini-web uses a real browser_profile") + if surface_key == "gemini-web" and proxy_url_env is not None: + raise ValueError("surface gemini-web does not use proxy_url_env") + if proxy_url_env is not None and surface_key not in {"openai-web", "microsoft-web", "meta-web"}: + raise ValueError(f"surface {surface_key} does not use proxy_url_env") + if surface_key == "openai-api" and api_key_env is None: + raise ValueError("surface openai-api requires an api_key_env") + if surface_key != "openai-api" and api_key_env is not None: + raise ValueError("only openai-api uses api_key_env") + if name in slots: + raise ValueError(f"duplicate slot name: {name}") + slots[name] = { + "name": name, + "surface": surface_key, + "account_label": account_label, + "browser_profile": browser_profile, + "google_account_index": google_account_index, + "network_label": network_label, + "proxy_url_env": proxy_url_env, + "api_key_env": api_key_env, + } + return slots + + +def resolve_slot(surface: str, *, slots_path: Path | None, slot_name: str | None) -> ExecutionSlot | None: + """Resolve one explicitly selected slot without automatic fallback.""" + if slot_name is None: + if slots_path is not None: + raise ValueError("--slots requires --slot") + return None + if slots_path is None: + slots_path = DEFAULT_SLOTS_PATH + slots = load_slots(slots_path) + if slot_name not in slots: + raise ValueError(f"unknown execution slot: {slot_name}") + slot = slots[slot_name] + if slot["surface"] != surface: + raise ValueError(f"slot {slot_name} belongs to {slot['surface']}, not {surface}") + return slot + + +def openai_api_key( + slot: ExecutionSlot | None, + *, + env_file: Path, + environ: Mapping[str, str] = os.environ, +) -> str: + """Resolve one selected OpenAI key without logging or mutating the environment.""" + variable = "OPENAI_API_KEY" if slot is None else slot.get("api_key_env") + if variable is None: + raise ValueError("the selected openai-api slot has no api_key_env") + value = secret_environment(env_file, environ=environ).get(variable) + if value is not None: + return value + raise ValueError(f"OpenAI API key variable {variable} is missing from the environment and {env_file}") + + +def secret_environment( + env_file: Path, + *, + environ: Mapping[str, str] = os.environ, +) -> dict[str, str]: + """Read nonempty dotenv secrets with nonempty process values taking precedence.""" + values: dict[str, str] = {} + if env_file.is_file(): + from dotenv import dotenv_values + + values.update( + (name, value.strip()) + for name, value in dotenv_values(env_file).items() + if isinstance(value, str) and value.strip() + ) + values.update((name, value.strip()) for name, value in environ.items() if value.strip()) + return values + + +def microsoft_api_configuration( + *, + env_file: Path, + environ: Mapping[str, str] = os.environ, +) -> tuple[str, str, str, str, str]: + """Resolve the Azure resources used by Microsoft checks.""" + values = secret_environment(env_file, environ=environ) + names = ( + "AZURE_CONTENT_SAFETY_ENDPOINT", + "AZURE_CONTENT_SAFETY_SUBSCRIPTION_ID", + "AZURE_CONTENT_SAFETY_RESOURCE_GROUP", + "AZURE_CONTENT_SAFETY_ACCOUNT_NAME", + "AZURE_ORACLE_STORAGE_RESOURCE_GROUP", + ) + missing = [name for name in names if name not in values] + if missing: + raise ValueError(f"{', '.join(missing)} missing from the environment and {env_file}") + return cast("tuple[str, str, str, str, str]", tuple(values[name] for name in names)) + + +def _write_json_new(path: Path, payload: object) -> None: + """Write a JSON document while refusing to replace an existing artifact.""" + if path.exists(): + raise ValueError(f"refusing to overwrite oracle artifact: {path}") + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _replace_json(path: Path, payload: object) -> None: + """Atomically replace a mutable result document.""" + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as stream: + json.dump(payload, stream, indent=2, sort_keys=True) + stream.write("\n") + temporary = Path(stream.name) + temporary.replace(path) + + +def _prepare_image(source: Path, upload: Path) -> Preparation: + """Strip AI metadata into UPLOAD and prove decoded pixels are unchanged.""" + source_pixels, width, height, source_format = _pixel_fingerprint(source) + from remove_ai_watermarks.metadata import strip_and_verify + + stripped, remaining = strip_and_verify(source, upload, keep_standard=True) + if remaining: + fields = ", ".join(sorted(remaining)) + raise RuntimeError(f"refusing oracle batch because AI metadata survived stripping: {fields}") + upload_pixels, upload_width, upload_height, upload_format = _pixel_fingerprint(stripped) + if (upload_pixels, upload_width, upload_height, upload_format) != ( + source_pixels, + width, + height, + source_format, + ): + raise RuntimeError("refusing oracle batch because metadata stripping changed the decoded pixels") + return { + "metadata_stripped": True, + "pixels_preserved": True, + "bytes_preserved": _sha256(source) == _sha256(upload), + "pixel_sha256": source_pixels, + "width": width, + "height": height, + "format": source_format.lower(), + } + + +def _prepare_binary(source: Path, upload: Path) -> Preparation: + """Copy video or audio bytes exactly without making a metadata claim.""" + shutil.copyfile(source, upload) + return { + "metadata_stripped": False, + "pixels_preserved": None, + "bytes_preserved": True, + "pixel_sha256": None, + "width": None, + "height": None, + "format": source.suffix.lower().removeprefix("."), + } + + +def prepare_batch( + surface: str, + sources: list[Path], + *, + output_dir: Path, + repository_root: Path = REPOSITORY_ROOT, + slot: ExecutionSlot | None = None, +) -> Path: + """Create a hash-bound, unsubmitted oracle batch outside the repository.""" + if surface not in SURFACES: + raise ValueError(f"unknown provider oracle surface: {surface}") + if slot is not None and slot["surface"] != surface: + raise ValueError(f"slot {slot['name']} belongs to {slot['surface']}, not {surface}") + if not sources: + raise ValueError("at least one source is required") + if _inside(output_dir, repository_root): + raise ValueError("oracle batches must be written outside the repository") + if output_dir.exists() and any(output_dir.iterdir()): + raise ValueError("output directory must not already contain files") + output_dir.mkdir(parents=True, exist_ok=True) + uploads_dir = output_dir / "uploads" + uploads_dir.mkdir() + + oracle = SURFACES[surface] + rows: list[BatchRow] = [] + source_hashes: set[str] = set() + for index, raw_source in enumerate(sources): + source = raw_source.resolve() + if not source.is_file(): + raise ValueError(f"oracle source is not a file: {source}") + media_type = _media_type(source) + if media_type not in oracle.media_types: + raise ValueError(f"{surface} tooling does not support {media_type} inputs") + source_sha256 = _sha256(source) + if source_sha256 in source_hashes: + raise ValueError(f"duplicate source bytes: {source}") + source_hashes.add(source_sha256) + upload = uploads_dir / f"{index:03d}-{source_sha256[:16]}{source.suffix.lower()}" + preparation = _prepare_image(source, upload) if media_type == "image" else _prepare_binary(source, upload) + upload_sha256 = _sha256(upload) + row: BatchRow = { + "artifact_id": f"{index:03d}-{upload_sha256[:16]}", + "index": index, + "source_path": str(source), + "source_sha256": source_sha256, + "upload_path": str(upload.relative_to(output_dir)), + "upload_sha256": upload_sha256, + "media_type": media_type, + **preparation, + } + rows.append(row) + + manifest: ManifestDocument = { + "format_version": FORMAT_VERSION, + "status": "unsubmitted", + "created_at": _utc_now(), + "provider": oracle.provider_key, + "surface": surface, + "oracle": oracle.to_dict(), + "slot": slot, + "row_count": len(rows), + "rows": rows, + } + manifest_path = output_dir / "manifest.json" + _write_json_new(manifest_path, manifest) + manifest_sha256 = _sha256(manifest_path) + digest_path = output_dir / "manifest.sha256" + digest_path.write_text(f"{manifest_sha256} manifest.json\n", encoding="utf-8") + results: ResultsDocument = { + "format_version": FORMAT_VERSION, + "manifest_sha256": manifest_sha256, + "provider": oracle.provider_key, + "surface": surface, + "rows": [ + { + "artifact_id": row["artifact_id"], + "upload_sha256": row["upload_sha256"], + "watermark_result": None, + "provenance_result": None, + "raw_response": None, + "checked_at": None, + } + for row in rows + ], + } + _write_json_new(output_dir / "results.json", results) + verify_batch(manifest_path) + return manifest_path + + +def load_batch(manifest_path: Path) -> tuple[ManifestDocument, ResultsDocument]: + """Load and structurally validate one immutable manifest and result file.""" + batch_root = manifest_path.parent + digest_parts = (batch_root / "manifest.sha256").read_text(encoding="utf-8").split() + if not digest_parts or _sha256(manifest_path) != digest_parts[0]: + raise ValueError("manifest hash mismatch") + raw_manifest: object = json.loads(manifest_path.read_text(encoding="utf-8")) + raw_results: object = json.loads((batch_root / "results.json").read_text(encoding="utf-8")) + if not isinstance(raw_manifest, dict) or not isinstance(raw_results, dict): + raise ValueError("oracle manifest and results must be JSON objects") + manifest = cast("dict[str, object]", raw_manifest) + results = cast("dict[str, object]", raw_results) + if manifest.get("format_version") != FORMAT_VERSION or results.get("format_version") != FORMAT_VERSION: + raise ValueError("unsupported oracle batch format version") + if manifest.get("provider") not in PROVIDER_KEYS: + raise ValueError("manifest names an unknown provider") + surface = manifest.get("surface") + if not isinstance(surface, str) or surface not in SURFACES: + raise ValueError("manifest names an unknown oracle surface") + if SURFACES[surface].provider_key != manifest["provider"]: + raise ValueError("manifest provider does not match its oracle surface") + if ( + results.get("provider") != manifest["provider"] + or results.get("surface") != surface + or results.get("manifest_sha256") != digest_parts[0] + ): + raise ValueError("results do not identify this oracle manifest") + raw_manifest_rows = manifest.get("rows") + raw_result_rows = results.get("rows") + if not isinstance(raw_manifest_rows, list): + raise ValueError("manifest rows must be a JSON array") + if not isinstance(raw_result_rows, list): + raise ValueError("result rows must be a JSON array") + manifest_rows = cast("list[object]", raw_manifest_rows) + result_rows = cast("list[object]", raw_result_rows) + if len(manifest_rows) != manifest.get("row_count"): + raise ValueError("manifest row count mismatch") + if len(result_rows) != len(manifest_rows): + raise ValueError("results row count mismatch") + if not all(isinstance(row, dict) for row in manifest_rows): + raise ValueError("manifest rows must be JSON objects") + if not all(isinstance(row, dict) for row in result_rows): + raise ValueError("result rows must be JSON objects") + return cast("ManifestDocument", manifest), cast("ResultsDocument", results) + + +def verify_batch(manifest_path: Path, *, require_complete: bool = False) -> dict[str, object]: + """Validate batch hashes, result identities, and recorded verdicts.""" + manifest_path = manifest_path.resolve() + if _inside(manifest_path.parent, REPOSITORY_ROOT): + raise ValueError("oracle batches must remain outside the repository") + manifest, results = load_batch(manifest_path) + result_counts: Counter[str] = Counter() + recorded = 0 + for expected, result in zip(manifest["rows"], results["rows"], strict=True): + source = Path(expected["source_path"]) + upload = manifest_path.parent / expected["upload_path"] + if _sha256(source) != expected["source_sha256"]: + raise ValueError(f"source hash mismatch for {expected['artifact_id']}") + if _sha256(upload) != expected["upload_sha256"]: + raise ValueError(f"upload hash mismatch for {expected['artifact_id']}") + identity = (result.get("artifact_id"), result.get("upload_sha256")) + expected_identity = (expected["artifact_id"], expected["upload_sha256"]) + if identity != expected_identity: + raise ValueError("result identity differs from the immutable manifest") + watermark_result = result.get("watermark_result") + if watermark_result is None: + if any(result.get(field) is not None for field in ("provenance_result", "raw_response", "checked_at")): + raise ValueError("partial oracle result row") + continue + if watermark_result not in WATERMARK_RESULTS: + raise ValueError(f"invalid watermark result: {watermark_result!r}") + if result.get("provenance_result") not in PROVENANCE_RESULTS: + raise ValueError("invalid or missing provenance result") + raw_response = result.get("raw_response") + if not isinstance(raw_response, str) or not raw_response.strip(): + raise ValueError("raw_response must preserve the nonempty verbatim oracle result") + checked_at = result.get("checked_at") + if not isinstance(checked_at, str): + raise ValueError("checked_at must be a timezone-aware ISO-8601 timestamp") + _aware_timestamp(checked_at) + recorded += 1 + result_counts[watermark_result] += 1 + + complete = recorded == len(manifest["rows"]) + if require_complete and not complete: + raise ValueError(f"oracle batch is incomplete: {recorded}/{len(manifest['rows'])} results recorded") + return { + "provider": manifest["provider"], + "surface": manifest["surface"], + "total": len(manifest["rows"]), + "recorded": recorded, + "complete": complete, + "watermark_results": dict(sorted(result_counts.items())), + } + + +def record_result( + manifest_path: Path, + *, + artifact_id: str, + watermark_result: str, + provenance_result: str, + raw_response: str, + checked_at: str, +) -> None: + """Record one verbatim manual result while refusing to overwrite evidence.""" + verify_batch(manifest_path) + if watermark_result not in WATERMARK_RESULTS: + raise ValueError(f"invalid watermark result: {watermark_result!r}") + if provenance_result not in PROVENANCE_RESULTS: + raise ValueError(f"invalid provenance result: {provenance_result!r}") + if not raw_response.strip(): + raise ValueError("raw_response must not be empty") + _aware_timestamp(checked_at) + _manifest, results = load_batch(manifest_path.resolve()) + matches = [row for row in results["rows"] if row.get("artifact_id") == artifact_id] + if len(matches) != 1: + raise ValueError(f"artifact_id must identify exactly one row: {artifact_id}") + row = matches[0] + if row.get("watermark_result") is not None: + raise ValueError(f"artifact {artifact_id} already has a recorded result") + row.update( + { + "watermark_result": watermark_result, + "provenance_result": provenance_result, + "raw_response": raw_response, + "checked_at": checked_at, + } + ) + _replace_json(manifest_path.parent / "results.json", results) + verify_batch(manifest_path) + + +def verify_openai(path: Path, *, acknowledge_upload: bool, api_key: str | None) -> object: + """Call the hardened package adapter, kept as a seam for contract tests.""" + from remove_ai_watermarks.openai_provenance import verify_openai_synthid + + return verify_openai_synthid(path, acknowledge_upload=acknowledge_upload, api_key=api_key) + + +def verify_microsoft( + path: Path, + *, + content_uri: str, + endpoint: str, + subscription_id: str, + resource_group: str, + account_name: str, + storage_resource_group: str, + acknowledge_upload: bool, +) -> object: + """Call the direct Microsoft adapter, kept as a seam for contract tests.""" + from provider_oracle_microsoft import verify_microsoft_provenance + + return verify_microsoft_provenance( + path, + content_uri=content_uri, + endpoint=endpoint, + subscription_id=subscription_id, + resource_group=resource_group, + account_name=account_name, + storage_resource_group=storage_resource_group, + acknowledge_upload=acknowledge_upload, + ) + + +@click.group() +def cli() -> None: + """Work with every provider watermark oracle used by the project.""" + + +@cli.command("list") +@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.") +def list_oracles(as_json: bool) -> None: + """List provider scope, supported media, and verification surfaces.""" + definitions = [SURFACES[key] for key in sorted(SURFACES)] + if as_json: + click.echo(json.dumps([definition.to_dict() for definition in definitions], indent=2, sort_keys=True)) + return + for definition in definitions: + media = ",".join(definition.media_types) + click.echo(f"{definition.key}\t{definition.automation}\t{media}\t{definition.surface}\t{definition.url}") + + +@cli.command() +@click.argument("surface", type=click.Choice(sorted(SURFACES))) +@click.option("--slots", "slots_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--slot", "slot_name") +def guide(surface: str, slots_path: Path | None, slot_name: str | None) -> None: + """Print the exact provider-specific workflow.""" + oracle = SURFACES[surface] + slot = resolve_slot(surface, slots_path=slots_path, slot_name=slot_name) + click.echo(f"Provider: {oracle.provider}") + click.echo(f"Surface key: {oracle.key}") + click.echo(f"Signal: {oracle.signal_family}") + click.echo(f"Surface: {oracle.surface}") + click.echo(f"URL: {oracle.url}") + click.echo(f"Media: {', '.join(oracle.media_types)}") + if oracle.prompt: + click.echo(f"Prompt: {oracle.prompt}") + click.echo(f"Result rule: {oracle.clean_result_note}") + if slot is not None: + google_account_index = slot.get("google_account_index") + proxy_url_env = slot.get("proxy_url_env") + api_key_env = slot.get("api_key_env") + click.echo(f"Execution slot: {slot['name']}") + click.echo(f"Account: {slot['account_label'] or 'not recorded'}") + click.echo(f"Browser profile: {slot['browser_profile'] or 'not recorded'}") + if google_account_index is not None: + click.echo(f"Google account index: {google_account_index}") + click.echo(f"Network: {slot['network_label']}") + if proxy_url_env is not None: + click.echo(f"Proxy environment variable: {proxy_url_env}") + if api_key_env is not None: + click.echo(f"OpenAI API key variable: {api_key_env}") + if oracle.automation == "playwright": + click.echo("Prepare a batch, then run it with run-web and an explicit upload acknowledgement.") + elif oracle.automation == "real_browser": + click.echo("Prepare a batch, then invoke the provider-oracles skill to drive the authenticated Chrome session.") + elif surface == "microsoft-api": + click.echo("Run check-microsoft with an HTTPS media URI whose bytes exactly match the local source.") + else: + click.echo("Prepare files first, submit one file at a time, and preserve the settled response verbatim.") + + +@cli.command() +@click.argument("config", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.") +def slots(config: Path, as_json: bool) -> None: + """List validated account/browser/network execution slots.""" + loaded = load_slots(config) + ordered = [loaded[name] for name in sorted(loaded)] + if as_json: + click.echo(json.dumps(ordered, indent=2, sort_keys=True)) + return + for slot in ordered: + google_account_index = slot.get("google_account_index") + google_index_text = str(google_account_index) if google_account_index is not None else "-" + click.echo( + f"{slot['name']}\t{slot['surface']}\t{slot['account_label'] or '-'}\t" + f"{slot['browser_profile'] or '-'}\t{google_index_text}\t" + f"{slot['network_label']}\t{slot.get('proxy_url_env') or '-'}\t" + f"{slot.get('api_key_env') or '-'}" + ) + + +@cli.command() +@click.argument("surface", type=click.Choice(sorted(SURFACES))) +@click.argument("sources", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--output", required=True, type=click.Path(file_okay=False, path_type=Path)) +@click.option("--slots", "slots_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--slot", "slot_name") +def prepare( + surface: str, + sources: tuple[Path, ...], + output: Path, + slots_path: Path | None, + slot_name: str | None, +) -> None: + """Prepare an immutable provider-oracle batch outside the repository.""" + slot = resolve_slot(surface, slots_path=slots_path, slot_name=slot_name) + manifest = prepare_batch(surface, list(sources), output_dir=output, slot=slot) + click.echo(str(manifest)) + + +@cli.command() +@click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--artifact-id", required=True) +@click.option("--watermark-result", required=True, type=click.Choice(sorted(WATERMARK_RESULTS))) +@click.option("--provenance-result", required=True, type=click.Choice(sorted(PROVENANCE_RESULTS))) +@click.option("--raw-response", required=True, help="Settled provider response, copied verbatim.") +@click.option("--checked-at", required=True, help="Timezone-aware ISO-8601 timestamp.") +def record( + manifest: Path, + artifact_id: str, + watermark_result: str, + provenance_result: str, + raw_response: str, + checked_at: str, +) -> None: + """Record one manual result in the batch's mutable results file.""" + record_result( + manifest, + artifact_id=artifact_id, + watermark_result=watermark_result, + provenance_result=provenance_result, + raw_response=raw_response, + checked_at=checked_at, + ) + click.echo(json.dumps(verify_batch(manifest), sort_keys=True)) + + +@cli.command() +@click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--require-complete", is_flag=True, help="Fail when any row has no result.") +def verify(manifest: Path, require_complete: bool) -> None: + """Verify immutable media and the recorded result document.""" + click.echo(json.dumps(verify_batch(manifest, require_complete=require_complete), sort_keys=True)) + + +@cli.command("run-web") +@click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--acknowledge-uploads", + is_flag=True, + help="Acknowledge uploads of every unrecorded manifest row to its named provider.", +) +@click.option("--headed/--headless", default=True, help="Show the isolated Playwright browser window.") +@click.option("--timeout", "timeout_seconds", type=click.FloatRange(min=1), default=120.0, show_default=True) +@click.option( + "--env-file", + type=click.Path(dir_okay=False, path_type=Path), + default=Path(".env"), + show_default=True, + help="Read the selected proxy URL variable from this dotenv file.", +) +def run_web( + manifest: Path, + acknowledge_uploads: bool, + headed: bool, + timeout_seconds: float, + env_file: Path, +) -> None: + """Submit a prepared non-Google Web batch through isolated Playwright.""" + from provider_oracle_web import run_web_batch + + report = run_web_batch( + manifest, + acknowledge_uploads=acknowledge_uploads, + headed=headed, + timeout_seconds=timeout_seconds, + env_file=env_file, + ) + click.echo(json.dumps(report, sort_keys=True)) + + +@cli.command("check-openai") +@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--acknowledge-upload", + is_flag=True, + help="Acknowledge exactly one metadata-stripped upload to OpenAI.", +) +@click.option("--slots", "slots_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--slot", "slot_name") +@click.option( + "--env-file", + type=click.Path(dir_okay=False, path_type=Path), + default=Path(".env"), + show_default=True, + help="Read the selected OpenAI API key variable from this dotenv file.", +) +def check_openai( + source: Path, + acknowledge_upload: bool, + slots_path: Path | None, + slot_name: str | None, + env_file: Path, +) -> None: + """Run one official OpenAI SynthID check through the hardened adapter.""" + if not acknowledge_upload: + raise click.UsageError("pass --acknowledge-upload to authorize exactly one OpenAI request") + slot = resolve_slot("openai-api", slots_path=slots_path, slot_name=slot_name) + api_key = openai_api_key(slot, env_file=env_file) + result = verify_openai(source, acknowledge_upload=True, api_key=api_key) + to_dict = getattr(result, "to_dict", None) + if not callable(to_dict): + raise RuntimeError("OpenAI oracle returned an unexpected result object") + raw_payload: object = to_dict() + if not isinstance(raw_payload, dict): + raise RuntimeError("OpenAI oracle returned an unexpected result payload") + payload = cast("dict[str, object]", raw_payload).copy() + payload["surface"] = "openai-api" + payload["execution_slot"] = slot + click.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +@cli.command("check-microsoft") +@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--content-uri", required=True, help="Private Azure Blob URI whose bytes exactly match SOURCE.") +@click.option( + "--acknowledge-upload", + is_flag=True, + help="Acknowledge exactly one Microsoft request that fetches the named URI.", +) +@click.option("--slots", "slots_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--slot", "slot_name") +@click.option( + "--env-file", + type=click.Path(dir_okay=False, path_type=Path), + default=Path(".env"), + show_default=True, + help="Read the Azure Content Safety and Blob resource identities from this dotenv file.", +) +def check_microsoft( + source: Path, + content_uri: str, + acknowledge_upload: bool, + slots_path: Path | None, + slot_name: str | None, + env_file: Path, +) -> None: + """Run one official Microsoft watermark and C2PA check.""" + if not acknowledge_upload: + raise click.UsageError("pass --acknowledge-upload to authorize exactly one Microsoft request") + slot = resolve_slot("microsoft-api", slots_path=slots_path, slot_name=slot_name) + endpoint, subscription_id, resource_group, account_name, storage_resource_group = microsoft_api_configuration( + env_file=env_file + ) + result = verify_microsoft( + source, + content_uri=content_uri, + endpoint=endpoint, + subscription_id=subscription_id, + resource_group=resource_group, + account_name=account_name, + storage_resource_group=storage_resource_group, + acknowledge_upload=True, + ) + to_dict = getattr(result, "to_dict", None) + if not callable(to_dict): + raise RuntimeError("Microsoft oracle returned an unexpected result object") + raw_payload: object = to_dict() + if not isinstance(raw_payload, dict): + raise RuntimeError("Microsoft oracle returned an unexpected result payload") + payload = cast("dict[str, object]", raw_payload).copy() + payload["surface"] = "microsoft-api" + payload["execution_slot"] = slot + click.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + cli() diff --git a/src/remove_ai_watermarks/openai_provenance.py b/src/remove_ai_watermarks/openai_provenance.py index 1d9482c..db46671 100644 --- a/src/remove_ai_watermarks/openai_provenance.py +++ b/src/remove_ai_watermarks/openai_provenance.py @@ -184,17 +184,26 @@ def _parse_synthid_result(payload: Mapping[str, Any]) -> OpenAISynthIDDetection: ) -def _default_client() -> Any: +def _default_client(*, api_key: str | None = None) -> Any: if not is_available(): raise RuntimeError(f"OpenAI SynthID verification needs the OpenAI SDK; {INSTALL_HINT}") openai_module = importlib.import_module("openai") client_factory = cast("Callable[..., Any]", openai_module.OpenAI) + http_client_factory = cast("Callable[..., Any]", openai_module.DefaultHttpxClient) + http_client = http_client_factory(trust_env=False) + client_options: dict[str, object] = { + "timeout": REQUEST_TIMEOUT_SECONDS, + "max_retries": MAX_AUTOMATIC_RETRIES, + "http_client": http_client, + } + if api_key is not None: + client_options["api_key"] = api_key try: - client = client_factory( - timeout=REQUEST_TIMEOUT_SECONDS, - max_retries=MAX_AUTOMATIC_RETRIES, - ) + client = client_factory(**client_options) except Exception as exc: + close = getattr(http_client, "close", None) + if callable(close): + close() raise RuntimeError(f"could not initialize the OpenAI client: {exc}") from exc if not hasattr(client, "content_provenance_checks"): raise RuntimeError(f"OpenAI SynthID verification needs openai>=2.52.0; {INSTALL_HINT}") @@ -276,6 +285,7 @@ def verify_openai_synthid( *, acknowledge_upload: bool = False, client: Any | None = None, + api_key: str | None = None, ) -> OpenAISynthIDDetection: """Verify OpenAI SynthID after stripping AI metadata without changing pixels. @@ -307,7 +317,7 @@ def verify_openai_synthid( if upload_bytes > MAX_UPLOAD_BYTES: raise ValueError("sanitized image exceeds the OpenAI Content Provenance 50 MiB upload limit") - api_client = client if client is not None else _default_client() + api_client = client if client is not None else _default_client(api_key=api_key) if not hasattr(api_client, "content_provenance_checks"): raise RuntimeError("OpenAI client does not expose content_provenance_checks; openai>=2.52.0 is required") request_context = { diff --git a/tests/test_openai_provenance.py b/tests/test_openai_provenance.py index 793d56b..6d0dfbf 100644 --- a/tests/test_openai_provenance.py +++ b/tests/test_openai_provenance.py @@ -289,7 +289,11 @@ def test_client_configuration_error_is_actionable( raise ValueError("OPENAI_API_KEY is missing") monkeypatch.setattr(provenance, "is_available", lambda: True) - monkeypatch.setattr(provenance.importlib, "import_module", lambda _name: SimpleNamespace(OpenAI=fail)) + monkeypatch.setattr( + provenance.importlib, + "import_module", + lambda _name: SimpleNamespace(OpenAI=fail, DefaultHttpxClient=lambda **_kwargs: object()), + ) with pytest.raises(RuntimeError, match=r"could not initialize.*OPENAI_API_KEY"): _verify(tmp_clean_png) @@ -297,6 +301,7 @@ def test_client_configuration_error_is_actionable( def test_default_client_bounds_one_acknowledged_upload(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[dict[str, Any]] = [] + http_client = object() expected = SimpleNamespace(content_provenance_checks=object()) def factory(**kwargs: Any) -> Any: @@ -304,17 +309,72 @@ def test_default_client_bounds_one_acknowledged_upload(monkeypatch: pytest.Monke return expected monkeypatch.setattr(provenance, "is_available", lambda: True) - monkeypatch.setattr(provenance.importlib, "import_module", lambda _name: SimpleNamespace(OpenAI=factory)) + monkeypatch.setattr( + provenance.importlib, + "import_module", + lambda _name: SimpleNamespace(OpenAI=factory, DefaultHttpxClient=lambda **_kwargs: http_client), + ) assert provenance._default_client() is expected assert calls == [ { "timeout": provenance.REQUEST_TIMEOUT_SECONDS, "max_retries": 0, + "http_client": http_client, } ] +def test_default_client_accepts_one_explicit_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[dict[str, Any]] = [] + http_client = object() + expected = SimpleNamespace(content_provenance_checks=object()) + + def factory(**kwargs: Any) -> Any: + calls.append(kwargs) + return expected + + monkeypatch.setattr(provenance, "is_available", lambda: True) + monkeypatch.setattr( + provenance.importlib, + "import_module", + lambda _name: SimpleNamespace(OpenAI=factory, DefaultHttpxClient=lambda **_kwargs: http_client), + ) + + assert provenance._default_client(api_key="test-research-key") is expected + assert calls == [ + { + "api_key": "test-research-key", + "timeout": provenance.REQUEST_TIMEOUT_SECONDS, + "max_retries": 0, + "http_client": http_client, + } + ] + + +def test_default_client_ignores_environment_proxy_settings(monkeypatch: pytest.MonkeyPatch) -> None: + client_calls: list[dict[str, Any]] = [] + transport_calls: list[dict[str, Any]] = [] + transport = object() + expected = SimpleNamespace(content_provenance_checks=object()) + + def client_factory(**kwargs: Any) -> Any: + client_calls.append(kwargs) + return expected + + def transport_factory(**kwargs: Any) -> object: + transport_calls.append(kwargs) + return transport + + module = SimpleNamespace(OpenAI=client_factory, DefaultHttpxClient=transport_factory) + monkeypatch.setattr(provenance, "is_available", lambda: True) + monkeypatch.setattr(provenance.importlib, "import_module", lambda _name: module) + + assert provenance._default_client(api_key="test-research-key") is expected + assert transport_calls == [{"trust_env": False}] + assert client_calls[0]["http_client"] is transport + + @pytest.mark.parametrize( ("status_code", "message"), [ diff --git a/tests/test_provider_oracle_microsoft.py b/tests/test_provider_oracle_microsoft.py new file mode 100644 index 0000000..4fc97c0 --- /dev/null +++ b/tests/test_provider_oracle_microsoft.py @@ -0,0 +1,261 @@ +"""Contract tests for the direct Microsoft Content Provenance adapter.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace +from typing import TYPE_CHECKING + +import httpx +import provider_oracle_microsoft as microsoft +import pytest + +if TYPE_CHECKING: + from collections.abc import Callable + +CONTENT_URI = "https://storageacct.blob.core.windows.net/oracle-inputs/sample.png" +ENDPOINT = "https://content-safety.example.test" +OPERATION_ID = "12345678-1234-1234-1234-123456789abc" + + +def _client(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(handler), follow_redirects=True) + + +def test_acknowledgement_is_required_before_media_or_key_access(tmp_clean_png: Path) -> None: + with pytest.raises(ValueError, match="acknowledge_upload=True"): + microsoft.verify_microsoft_provenance( + tmp_clean_png, + content_uri=CONTENT_URI, + endpoint=ENDPOINT, + subscription_id="subscription", + resource_group="resource-group", + account_name="content-safety", + storage_resource_group="storage-resource-group", + ) + + +def test_remote_bytes_must_match_before_token_or_submit(tmp_clean_png: Path) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, content=b"different bytes") + + def key_loader(_subscription_id: str, _resource_group: str, _account_name: str) -> str: + pytest.fail("key must not be loaded for mismatched media") + + def blob_hash_loader(_subscription_id: str, _resource_group: str, _content_uri: str) -> str: + return "different-hash" + + with _client(handler) as client, pytest.raises(ValueError, match="do not match"): + microsoft.verify_microsoft_provenance( + tmp_clean_png, + content_uri=CONTENT_URI, + endpoint=ENDPOINT, + subscription_id="subscription", + resource_group="resource-group", + account_name="content-safety", + storage_resource_group="storage-resource-group", + acknowledge_upload=True, + client=client, + key_loader=key_loader, + blob_hash_loader=blob_hash_loader, + ) + + assert requests == [] + + +def test_private_blob_preflight_keeps_storage_key_out_of_process_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + secret = "test-storage-secret" + + def cli_value(_arguments: list[str], *, value_label: str) -> str: + assert value_label == "Azure Storage resource key" + return secret + + def run(command: list[str], **kwargs: object) -> SimpleNamespace: + assert secret not in command + environment = kwargs["env"] + assert isinstance(environment, dict) + assert environment["AZURE_STORAGE_KEY"] == secret + download = Path(command[command.index("--file") + 1]) + download.write_bytes(b"private blob bytes") + return SimpleNamespace(returncode=0, stderr="") + + monkeypatch.setattr(microsoft, "_azure_cli_value", cli_value) + monkeypatch.setattr(microsoft.shutil, "which", lambda _name: "/usr/bin/az") + monkeypatch.setattr(microsoft.subprocess, "run", run) + + digest = microsoft.azure_blob_sha256("subscription", "resource-group", CONTENT_URI) + + assert digest == hashlib.sha256(b"private blob bytes").hexdigest() + + +@pytest.mark.parametrize( + ("result", "expected_status", "expected_provenance"), + [ + ({"outcome": "NoProvenanceDetected"}, "not_detected", "absent"), + ( + { + "outcome": "ProvenanceDetected", + "results": [{"type": "C2PA", "provider": "Microsoft"}], + }, + "not_detected", + "present", + ), + ( + { + "outcome": "ProvenanceDetected", + "results": [ + { + "type": "Watermark", + "provider": "Microsoft", + "modelName": "Image Creator", + "timestamp": "2026-09-08T00:00:00Z", + } + ], + }, + "detected", + "absent", + ), + ], +) +def test_direct_check_separates_watermark_from_c2pa( + tmp_clean_png: Path, + result: dict[str, object], + expected_status: str, + expected_provenance: str, +) -> None: + source_hash = hashlib.sha256(tmp_clean_png.read_bytes()).hexdigest() + requests: list[httpx.Request] = [] + poll_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal poll_count + requests.append(request) + if request.method == "POST": + assert json.loads(request.content) == {"content": {"uri": CONTENT_URI}} + return httpx.Response(202, json={"id": OPERATION_ID}) + poll_count += 1 + state = "Running" if poll_count == 1 else "Succeeded" + payload: dict[str, object] = {"id": OPERATION_ID, "status": state} + if state == "Succeeded": + payload["result"] = result + return httpx.Response(200, json=payload) + + def key_loader(_subscription_id: str, _resource_group: str, _account_name: str) -> str: + return "test-key" + + def blob_hash_loader(_subscription_id: str, _resource_group: str, _content_uri: str) -> str: + return source_hash + + def sleeper(_seconds: float) -> None: + return None + + with _client(handler) as client: + detection = microsoft.verify_microsoft_provenance( + tmp_clean_png, + content_uri=CONTENT_URI, + endpoint=ENDPOINT, + subscription_id="subscription", + resource_group="resource-group", + account_name="content-safety", + storage_resource_group="storage-resource-group", + acknowledge_upload=True, + client=client, + key_loader=key_loader, + blob_hash_loader=blob_hash_loader, + sleeper=sleeper, + ) + + assert detection.status == expected_status + assert detection.provenance_status == expected_provenance + assert detection.metadata_used_for_verdict is False + assert [request.method for request in requests] == ["POST", "GET", "GET"] + for request in requests: + assert request.headers["Ocp-Apim-Subscription-Key"] == "test-key" + + +@pytest.mark.parametrize( + "payload", + [ + {"id": OPERATION_ID, "status": "Failed"}, + {"id": OPERATION_ID, "status": "Succeeded", "result": {"outcome": "Unknown"}}, + { + "id": OPERATION_ID, + "status": "Succeeded", + "result": {"outcome": "ProvenanceDetected", "results": [{"type": "Other"}]}, + }, + ], +) +def test_parser_rejects_failed_or_unknown_provider_results(payload: dict[str, object]) -> None: + with pytest.raises(RuntimeError): + microsoft.parse_detection(payload) + + +@pytest.mark.parametrize( + ("content_uri", "endpoint"), + [ + ("http://media.example.test/sample.png", ENDPOINT), + ("https://user:password@media.example.test/sample.png", ENDPOINT), + ("https://media.example.test/sample.png?token=secret", ENDPOINT), + ("https://media.example.test/sample.png", ENDPOINT), + (CONTENT_URI, "http://content-safety.example.test"), + ], +) +def test_urls_require_https_without_credentials( + tmp_clean_png: Path, + content_uri: str, + endpoint: str, +) -> None: + with pytest.raises(ValueError, match=r"HTTPS URL|Azure Blob Storage"): + microsoft.verify_microsoft_provenance( + tmp_clean_png, + content_uri=content_uri, + endpoint=endpoint, + subscription_id="subscription", + resource_group="resource-group", + account_name="content-safety", + storage_resource_group="storage-resource-group", + acknowledge_upload=True, + ) + + +def test_poll_timeout_uses_injected_clock_without_waiting(tmp_clean_png: Path) -> None: + source_hash = hashlib.sha256(tmp_clean_png.read_bytes()).hexdigest() + clock_values = iter([0.0, 121.0]) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response(202, json={"id": OPERATION_ID}) + return httpx.Response(200, json={"id": OPERATION_ID, "status": "Running"}) + + def key_loader(_subscription_id: str, _resource_group: str, _account_name: str) -> str: + return "test-key" + + def blob_hash_loader(_subscription_id: str, _resource_group: str, _content_uri: str) -> str: + return source_hash + + def sleeper(_seconds: float) -> None: + pytest.fail("timeout must occur before sleeping") + + with _client(handler) as client, pytest.raises(RuntimeError, match="timed out"): + microsoft.verify_microsoft_provenance( + tmp_clean_png, + content_uri=CONTENT_URI, + endpoint=ENDPOINT, + subscription_id="subscription", + resource_group="resource-group", + account_name="content-safety", + storage_resource_group="storage-resource-group", + acknowledge_upload=True, + client=client, + key_loader=key_loader, + blob_hash_loader=blob_hash_loader, + sleeper=sleeper, + monotonic=lambda: next(clock_values), + ) diff --git a/tests/test_provider_oracle_web.py b/tests/test_provider_oracle_web.py new file mode 100644 index 0000000..0fbeb83 --- /dev/null +++ b/tests/test_provider_oracle_web.py @@ -0,0 +1,378 @@ +"""Contract tests for isolated Playwright provider-oracle automation.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import provider_oracle_web as web +import provider_oracles as oracles +import pytest + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.mark.parametrize( + ("surface", "page_text", "watermark", "provenance"), + [ + ( + "openai-web", + "Generated with OpenAI tools\nSynthID detected\nContent Credentials not detected", + "detected", + "absent", + ), + ( + "openai-web", + "No OpenAI signals detected", + "not_detected", + "unavailable", + ), + ( + "microsoft-web", + "Microsoft AI detected\nWatermark detected\nC2PA detected", + "detected", + "present", + ), + ( + "microsoft-web", + "Inconclusive", + "not_detected", + "unavailable", + ), + ( + "microsoft-web", + "No watermark detected\nC2PA detected", + "not_detected", + "present", + ), + ( + "meta-web", + "AI signature from Meta was found\nUpload another file", + "detected", + "unavailable", + ), + ( + "meta-web", + "AI signatures were found\nUpload another file", + "detected", + "unavailable", + ), + ( + "meta-web", + "No AI signatures from Meta were found\nUpload another file", + "not_detected", + "unavailable", + ), + ], +) +def test_parse_provider_result( + surface: str, + page_text: str, + watermark: str, + provenance: str, +) -> None: + verdict = web.parse_provider_result(surface, page_text) + + assert verdict.watermark_result == watermark + assert verdict.provenance_result == provenance + assert verdict.raw_response == page_text + + +def test_parse_provider_result_keeps_throttle_separate_from_clean() -> None: + verdict = web.parse_provider_result( + "meta-web", + "You\u2019ve reached the daily limit for identifications. Try again tomorrow", + ) + + assert verdict.watermark_result == "refused" + assert verdict.provenance_result == "unavailable" + + +def test_openai_static_explanation_is_not_a_settled_result() -> None: + page_text = ( + "Verify OpenAI-generated content\n" + "Upload an image or audio file to check for signals that it was generated with OpenAI tools.\n" + "Upload a file\n" + "This tool checks whether an uploaded file contains provenance signals associated with OpenAI tools." + ) + + assert not web._settled("openai-web", page_text) + assert web.parse_provider_result("openai-web", page_text).watermark_result == "indeterminate" + + +def test_openai_provider_error_is_a_settled_indeterminate_result() -> None: + page_text = "Something went wrong. Please try again. If the issue persists, contact support." + + assert web._settled("openai-web", page_text) + assert web.parse_provider_result("openai-web", page_text).watermark_result == "indeterminate" + + +@pytest.mark.parametrize( + ("surface", "ready_state"), + [("meta-web", "networkidle"), ("microsoft-web", "networkidle"), ("openai-web", "load")], +) +def test_react_uploads_wait_for_surface_readiness_before_assigning_the_file( + tmp_path: Path, + surface: str, + ready_state: str, +) -> None: + events: list[str] = [] + + class FakeInput: + def wait_for(self, *, state: str, timeout: int) -> None: + assert state == "attached" + assert timeout == 5_000 + events.append("input-attached") + + def set_input_files(self, path: str, *, timeout: int) -> None: + assert path == str(tmp_path / "upload.webp") + assert timeout == 5_000 + events.append("file-assigned") + + class FakeLocator: + @property + def first(self) -> FakeInput: + return FakeInput() + + class FakePage: + def wait_for_load_state(self, state: str, *, timeout: int) -> None: + assert state == ready_state + assert timeout == 5_000 + events.append(f"ready:{state}") + + def locator(self, selector: str) -> FakeLocator: + assert selector == 'input[type="file"]' + events.append("input-located") + return FakeLocator() + + web._set_upload_file(FakePage(), surface, tmp_path / "upload.webp", 5.0) # type: ignore[arg-type] + + assert events == [f"ready:{ready_state}", "input-located", "input-attached", "file-assigned"] + + +def test_proxy_settings_are_loaded_from_an_environment_reference() -> None: + slot: oracles.ExecutionSlot = { + "name": "openai-west", + "surface": "openai-web", + "account_label": None, + "browser_profile": None, + "google_account_index": None, + "network_label": "west-egress", + "proxy_url_env": "OPENAI_WEST_PROXY", + } + + proxy = web.proxy_settings(slot, {"OPENAI_WEST_PROXY": "http://alice:secret@proxy.example:8080"}) + + assert proxy == { + "server": "http://proxy.example:8080", + "username": "alice", + "password": "secret", + } + + +def test_proxy_settings_do_not_treat_a_missing_secret_as_direct_access() -> None: + slot: oracles.ExecutionSlot = { + "name": "openai-west", + "surface": "openai-web", + "account_label": None, + "browser_profile": None, + "google_account_index": None, + "network_label": "west-egress", + "proxy_url_env": "OPENAI_WEST_PROXY", + } + + with pytest.raises(ValueError, match="OPENAI_WEST_PROXY is not set"): + web.proxy_settings(slot, {}) + + +def test_proxy_context_accepts_proxy_certificate() -> None: + calls: list[dict[str, object]] = [] + + class FakeBrowser: + def new_context(self, **kwargs: object) -> object: + calls.append(kwargs) + return object() + + proxy = {"server": "http://proxy.example:9999", "username": "route", "password": "secret"} + + web._new_context(FakeBrowser(), proxy) # type: ignore[arg-type] + + assert calls == [{"proxy": proxy, "ignore_https_errors": True}] + + +def test_proxy_browser_accepts_proxy_certificate() -> None: + calls: list[dict[str, object]] = [] + + class FakeChromium: + def launch(self, **kwargs: object) -> object: + calls.append(kwargs) + return object() + + proxy = {"server": "http://proxy.example:9999", "username": "route", "password": "secret"} + + web._launch_browser(FakeChromium(), proxy, headed=False) # type: ignore[arg-type] + + assert calls == [{"headless": True, "args": ["--ignore-certificate-errors"]}] + + +def test_direct_browser_keeps_normal_certificate_checks() -> None: + calls: list[dict[str, object]] = [] + + class FakeChromium: + def launch(self, **kwargs: object) -> object: + calls.append(kwargs) + return object() + + web._launch_browser(FakeChromium(), None, headed=True) # type: ignore[arg-type] + + assert calls == [{"headless": False}] + + +def test_run_web_batch_records_playwright_result( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + tmp_clean_png: Path, +) -> None: + slot: oracles.ExecutionSlot = { + "name": "meta-direct", + "surface": "meta-web", + "account_label": None, + "browser_profile": None, + "google_account_index": None, + "network_label": "home", + "proxy_url_env": None, + } + manifest_path = oracles.prepare_batch( + "meta-web", + [tmp_clean_png], + output_dir=tmp_path / "oracle-batch", + repository_root=oracles.REPOSITORY_ROOT, + slot=slot, + ) + calls: list[tuple[str, list[Path], dict[str, str] | None, bool, float]] = [] + + def submit( + surface: str, + uploads: list[Path], + proxy: dict[str, str] | None, + *, + headed: bool, + timeout_seconds: float, + ) -> list[web.WebVerdict]: + calls.append((surface, uploads, proxy, headed, timeout_seconds)) + return [ + web.WebVerdict( + watermark_result="detected", + provenance_result="unavailable", + raw_response="AI signature from Meta was found\nUpload another file", + ) + ] + + monkeypatch.setattr(web, "_submit_batch", submit) + report = web.run_web_batch( + manifest_path, + acknowledge_uploads=True, + headed=False, + timeout_seconds=30, + environ={}, + ) + + assert report["complete"] is True + assert calls[0][0] == "meta-web" + assert calls[0][2] is None + results = json.loads((manifest_path.parent / "results.json").read_text(encoding="utf-8")) + assert results["rows"][0]["watermark_result"] == "detected" + + +def test_run_web_batch_loads_the_selected_proxy_from_dotenv( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + tmp_clean_png: Path, +) -> None: + slot: oracles.ExecutionSlot = { + "name": "openai-thordata-us", + "surface": "openai-web", + "account_label": None, + "browser_profile": None, + "google_account_index": None, + "network_label": "thordata-us-residential", + "proxy_url_env": "THORDATA_ROUTE_US", + } + manifest_path = oracles.prepare_batch( + "openai-web", + [tmp_clean_png], + output_dir=tmp_path / "oracle-batch", + repository_root=oracles.REPOSITORY_ROOT, + slot=slot, + ) + env_path = tmp_path / ".env" + env_path.write_text( + "THORDATA_PASSWORD=test-thordata-password\n" + "THORDATA_ROUTE_US=http://td-customer-user-country-US:${THORDATA_PASSWORD}" + "@proxy.thordata.test:9999\n", + encoding="utf-8", + ) + proxies: list[dict[str, str] | None] = [] + + def submit( + _surface: str, + _uploads: list[Path], + proxy: dict[str, str] | None, + *, + headed: bool, + timeout_seconds: float, + ) -> list[web.WebVerdict]: + assert headed is False + assert timeout_seconds == 30 + proxies.append(proxy) + return [web.WebVerdict("not_detected", "unavailable", "No OpenAI signals detected")] + + monkeypatch.setattr(web, "_submit_batch", submit) + report = web.run_web_batch( + manifest_path, + acknowledge_uploads=True, + headed=False, + timeout_seconds=30, + env_file=env_path, + environ={}, + ) + + assert report["watermark_results"] == {"not_detected": 1} + assert proxies == [ + { + "server": "http://proxy.thordata.test:9999", + "username": "td-customer-user-country-US", + "password": "test-thordata-password", + } + ] + + +def test_run_web_batch_requires_explicit_upload_acknowledgement( + tmp_path: Path, + tmp_clean_png: Path, +) -> None: + manifest_path = oracles.prepare_batch( + "meta-web", + [tmp_clean_png], + output_dir=tmp_path / "oracle-batch", + repository_root=oracles.REPOSITORY_ROOT, + ) + + with pytest.raises(ValueError, match="acknowledge_uploads"): + web.run_web_batch(manifest_path, acknowledge_uploads=False) + + +def test_run_web_batch_refuses_google_real_browser_workflow( + tmp_path: Path, + tmp_clean_png: Path, +) -> None: + manifest_path = oracles.prepare_batch( + "gemini-web", + [tmp_clean_png], + output_dir=tmp_path / "oracle-batch", + repository_root=oracles.REPOSITORY_ROOT, + ) + + with pytest.raises(ValueError, match="real Chrome"): + web.run_web_batch(manifest_path, acknowledge_uploads=True) diff --git a/tests/test_provider_oracles.py b/tests/test_provider_oracles.py new file mode 100644 index 0000000..d4203bf --- /dev/null +++ b/tests/test_provider_oracles.py @@ -0,0 +1,616 @@ +"""Contract tests for the unified development-only provider-oracle tooling.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import TYPE_CHECKING + +import provider_oracles as oracles +import pytest +from click.testing import CliRunner +from PIL import Image + +if TYPE_CHECKING: + from pathlib import Path + + +def test_catalog_covers_every_project_vendor_oracle_surface() -> None: + assert set(oracles.SURFACES) == { + "gemini-web", + "meta-web", + "microsoft-api", + "microsoft-web", + "openai-api", + "openai-web", + } + assert {"google", "meta", "microsoft", "openai"} == oracles.PROVIDER_KEYS + assert oracles.SURFACES["openai-api"].automation == "api" + assert oracles.SURFACES["microsoft-api"].automation == "api" + assert oracles.SURFACES["gemini-web"].automation == "real_browser" + assert oracles.SURFACES["meta-web"].automation == "playwright" + assert oracles.SURFACES["gemini-web"].media_types == ("image", "video", "audio") + + +def test_default_slot_config_is_repository_local() -> None: + assert oracles.DEFAULT_SLOTS_PATH == oracles.REPOSITORY_ROOT / ".oracle-slots.json" + + +def test_list_json_exposes_scope_and_surface() -> None: + result = CliRunner().invoke(oracles.cli, ["list", "--json"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert [row["key"] for row in payload] == [ + "gemini-web", + "meta-web", + "microsoft-api", + "microsoft-web", + "openai-api", + "openai-web", + ] + assert next(row for row in payload if row["key"] == "meta-web")["signal_family"] == "content_seal" + + +def test_slots_support_multiple_accounts_and_networks(tmp_path: Path) -> None: + config = tmp_path / "slots.json" + config.write_text( + json.dumps( + { + "format_version": 1, + "slots": [ + { + "name": "gemini-personal", + "surface": "gemini-web", + "account_label": "personal-a", + "browser_profile": "Profile 2", + "google_account_index": 0, + "network_label": "home", + }, + { + "name": "gemini-research", + "surface": "gemini-web", + "account_label": "research-b", + "browser_profile": "Profile 4", + "google_account_index": 2, + "network_label": "office", + }, + { + "name": "openai-west", + "surface": "openai-web", + "account_label": None, + "browser_profile": None, + "network_label": "west-egress", + "proxy_url_env": "OPENAI_WEST_PROXY", + }, + { + "name": "openai-api-1", + "surface": "openai-api", + "account_label": "project-1", + "browser_profile": None, + "network_label": "home", + "api_key_env": "OPENAI_API_KEY_1", + }, + ], + } + ), + encoding="utf-8", + ) + + slots = oracles.load_slots(config) + + assert set(slots) == { + "gemini-personal", + "gemini-research", + "openai-api-1", + "openai-west", + } + assert slots["gemini-research"]["account_label"] == "research-b" + assert slots["gemini-research"]["google_account_index"] == 2 + assert slots["openai-west"]["network_label"] == "west-egress" + assert slots["openai-west"]["proxy_url_env"] == "OPENAI_WEST_PROXY" + assert slots["openai-api-1"]["api_key_env"] == "OPENAI_API_KEY_1" + + +def test_named_slot_uses_repository_local_config_by_default( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = tmp_path / "slots.json" + config.write_text( + json.dumps( + { + "format_version": 1, + "slots": [ + { + "name": "openai-api-1", + "surface": "openai-api", + "account_label": "project-1", + "browser_profile": None, + "network_label": "home", + "api_key_env": "OPENAI_API_KEY_1", + } + ], + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(oracles, "DEFAULT_SLOTS_PATH", config) + + slot = oracles.resolve_slot("openai-api", slots_path=None, slot_name="openai-api-1") + + assert slot is not None + assert slot["api_key_env"] == "OPENAI_API_KEY_1" + + +def test_slots_override_requires_a_named_slot(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="--slots requires --slot"): + oracles.resolve_slot("openai-api", slots_path=tmp_path / "slots.json", slot_name=None) + + +@pytest.mark.parametrize( + ("slot", "message"), + [ + ( + { + "name": "gemini-unidentified", + "surface": "gemini-web", + "account_label": "personal-a", + "browser_profile": "Profile 2", + "google_account_index": 0, + "network_label": "home", + "proxy_url_env": "GOOGLE_PROXY", + }, + "does not use proxy_url_env", + ), + ( + { + "name": "gemini-no-profile", + "surface": "gemini-web", + "account_label": "personal-a", + "browser_profile": None, + "google_account_index": 0, + "network_label": "home", + }, + "requires a browser_profile", + ), + ( + { + "name": "openai-api-proxied", + "surface": "openai-api", + "account_label": "project-1", + "browser_profile": None, + "network_label": "proxy", + "proxy_url_env": "THORDATA_ROUTE_US", + "api_key_env": "OPENAI_API_KEY_1", + }, + "does not use proxy_url_env", + ), + ( + { + "name": "gemini-no-account-index", + "surface": "gemini-web", + "account_label": "personal-a", + "browser_profile": "Profile 2", + "network_label": "home", + }, + "requires a google_account_index", + ), + ( + { + "name": "openai-api-browser", + "surface": "openai-api", + "account_label": "project-a", + "browser_profile": "Profile 2", + "network_label": "home", + }, + "does not use a browser_profile", + ), + ( + { + "name": "openai-api-no-key", + "surface": "openai-api", + "account_label": "project-a", + "browser_profile": None, + "network_label": "home", + }, + "requires an api_key_env", + ), + ( + { + "name": "meta-with-api-key", + "surface": "meta-web", + "account_label": None, + "browser_profile": None, + "network_label": "home", + "api_key_env": "OPENAI_API_KEY_1", + }, + "only openai-api uses api_key_env", + ), + ], +) +def test_slots_require_the_identity_needed_by_the_surface( + tmp_path: Path, + slot: dict[str, str | None], + message: str, +) -> None: + config = tmp_path / "slots.json" + config.write_text(json.dumps({"format_version": 1, "slots": [slot]}), encoding="utf-8") + + with pytest.raises(ValueError, match=message): + oracles.load_slots(config) + + +def test_prepare_snapshots_the_selected_execution_slot(tmp_path: Path, tmp_clean_png: Path) -> None: + slot: oracles.ExecutionSlot = { + "name": "gemini-research", + "surface": "gemini-web", + "account_label": "research-b", + "browser_profile": "Profile 4", + "google_account_index": 2, + "network_label": "office", + } + + manifest_path = oracles.prepare_batch( + "gemini-web", + [tmp_clean_png], + output_dir=tmp_path / "oracle-batch", + repository_root=oracles.REPOSITORY_ROOT, + slot=slot, + ) + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assert manifest["slot"] == slot + + +def test_prepare_strips_image_metadata_without_changing_pixels( + tmp_path: Path, + tmp_png_with_ai_metadata: Path, +) -> None: + output_dir = tmp_path / "oracle-batch" + + manifest_path = oracles.prepare_batch( + "gemini-web", + [tmp_png_with_ai_metadata], + output_dir=output_dir, + repository_root=oracles.REPOSITORY_ROOT, + ) + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + row = manifest["rows"][0] + upload = output_dir / row["upload_path"] + assert row["media_type"] == "image" + assert row["metadata_stripped"] is True + assert row["pixels_preserved"] is True + assert row["source_sha256"] != row["upload_sha256"] + with Image.open(upload) as image: + assert "parameters" not in image.info + assert "prompt" not in image.info + assert oracles.verify_batch(manifest_path)["complete"] is False + + +def test_prepare_keeps_non_image_bytes_exact(tmp_path: Path) -> None: + source = tmp_path / "clip.mp4" + source.write_bytes(b"synthetic video fixture") + output_dir = tmp_path / "oracle-batch" + + manifest_path = oracles.prepare_batch( + "gemini-web", + [source], + output_dir=output_dir, + repository_root=oracles.REPOSITORY_ROOT, + ) + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + row = manifest["rows"][0] + assert row["media_type"] == "video" + assert row["metadata_stripped"] is False + assert row["bytes_preserved"] is True + assert (output_dir / row["upload_path"]).read_bytes() == source.read_bytes() + + +def test_prepare_rejects_unsupported_provider_media(tmp_path: Path) -> None: + source = tmp_path / "clip.mp4" + source.write_bytes(b"synthetic video fixture") + + with pytest.raises(ValueError, match="does not support video"): + oracles.prepare_batch( + "openai-web", + [source], + output_dir=tmp_path / "oracle-batch", + repository_root=oracles.REPOSITORY_ROOT, + ) + + +def test_record_and_verify_complete_batch(tmp_path: Path, tmp_clean_png: Path) -> None: + output_dir = tmp_path / "oracle-batch" + manifest_path = oracles.prepare_batch( + "meta-web", + [tmp_clean_png], + output_dir=output_dir, + repository_root=oracles.REPOSITORY_ROOT, + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + oracles.record_result( + manifest_path, + artifact_id=manifest["rows"][0]["artifact_id"], + watermark_result="detected", + provenance_result="absent", + raw_response="AI signature from Meta was found", + checked_at="2026-09-07T18:30:00Z", + ) + + report = oracles.verify_batch(manifest_path, require_complete=True) + assert report == { + "provider": "meta", + "surface": "meta-web", + "total": 1, + "recorded": 1, + "complete": True, + "watermark_results": {"detected": 1}, + } + + +def test_record_refuses_to_overwrite_result(tmp_path: Path, tmp_clean_png: Path) -> None: + manifest_path = oracles.prepare_batch( + "microsoft-web", + [tmp_clean_png], + output_dir=tmp_path / "oracle-batch", + repository_root=oracles.REPOSITORY_ROOT, + ) + artifact_id = json.loads(manifest_path.read_text(encoding="utf-8"))["rows"][0]["artifact_id"] + kwargs = { + "artifact_id": artifact_id, + "watermark_result": "not_detected", + "provenance_result": "absent", + "raw_response": "Inconclusive", + "checked_at": "2026-09-07T18:30:00Z", + } + oracles.record_result(manifest_path, **kwargs) + + with pytest.raises(ValueError, match="already has a recorded result"): + oracles.record_result(manifest_path, **kwargs) + + +def test_verify_rejects_tampered_upload(tmp_path: Path, tmp_clean_png: Path) -> None: + output_dir = tmp_path / "oracle-batch" + manifest_path = oracles.prepare_batch( + "gemini-web", + [tmp_clean_png], + output_dir=output_dir, + repository_root=oracles.REPOSITORY_ROOT, + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + (output_dir / manifest["rows"][0]["upload_path"]).write_bytes(b"tampered") + + with pytest.raises(ValueError, match="upload hash mismatch"): + oracles.verify_batch(manifest_path) + + +def test_openai_check_requires_acknowledgement(tmp_clean_png: Path) -> None: + result = CliRunner().invoke(oracles.cli, ["check-openai", str(tmp_clean_png)]) + + assert result.exit_code != 0 + assert "--acknowledge-upload" in result.output + + +def test_openai_check_uses_the_hardened_adapter( + monkeypatch: pytest.MonkeyPatch, + tmp_clean_png: Path, +) -> None: + calls: list[tuple[Path, bool, str | None]] = [] + + def verify(path: Path, *, acknowledge_upload: bool, api_key: str | None) -> SimpleNamespace: + calls.append((path, acknowledge_upload, api_key)) + return SimpleNamespace(to_dict=lambda: {"status": "detected", "provider_scope": "openai"}) + + monkeypatch.setattr(oracles, "verify_openai", verify) + result = CliRunner().invoke( + oracles.cli, + ["check-openai", str(tmp_clean_png), "--acknowledge-upload"], + env={"OPENAI_API_KEY": "test-default-key"}, + ) + + assert result.exit_code == 0, result.output + assert calls == [(tmp_clean_png, True, "test-default-key")] + assert json.loads(result.output)["status"] == "detected" + + +def test_openai_check_selects_a_named_key_from_dotenv( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + tmp_clean_png: Path, +) -> None: + slots_path = tmp_path / "slots.json" + slots_path.write_text( + json.dumps( + { + "format_version": 1, + "slots": [ + { + "name": "openai-api-2", + "surface": "openai-api", + "account_label": "project-2", + "browser_profile": None, + "network_label": "home", + "api_key_env": "OPENAI_API_KEY_2", + } + ], + } + ), + encoding="utf-8", + ) + env_path = tmp_path / ".env" + env_path.write_text( + "OPENAI_API_KEY_1=test-first-key\nOPENAI_API_KEY_2=test-second-key\n", + encoding="utf-8", + ) + calls: list[str | None] = [] + + def verify(_path: Path, *, acknowledge_upload: bool, api_key: str | None) -> SimpleNamespace: + assert acknowledge_upload is True + calls.append(api_key) + return SimpleNamespace(to_dict=lambda: {"status": "not_detected"}) + + monkeypatch.setattr(oracles, "verify_openai", verify) + result = CliRunner().invoke( + oracles.cli, + [ + "check-openai", + str(tmp_clean_png), + "--acknowledge-upload", + "--slots", + str(slots_path), + "--slot", + "openai-api-2", + "--env-file", + str(env_path), + ], + env={"OPENAI_API_KEY_1": "", "OPENAI_API_KEY_2": ""}, + ) + + assert result.exit_code == 0, result.output + assert calls == ["test-second-key"] + assert "test-second-key" not in result.output + + +def test_secret_environment_interpolates_proxy_routes_without_mutating_process( + tmp_path: Path, +) -> None: + env_path = tmp_path / ".env" + env_path.write_text( + "THORDATA_PASSWORD=test-thordata-password\n" + "THORDATA_ROUTE_US=http://td-customer-user-country-US:${THORDATA_PASSWORD}" + "@proxy.thordata.test:9999\n", + encoding="utf-8", + ) + + values = oracles.secret_environment(env_path, environ={}) + + assert values["THORDATA_PASSWORD"] == "test-thordata-password" + assert values["THORDATA_ROUTE_US"] == ( + "http://td-customer-user-country-US:test-thordata-password@proxy.thordata.test:9999" + ) + + +def test_nonempty_process_secret_overrides_dotenv(tmp_path: Path) -> None: + env_path = tmp_path / ".env" + env_path.write_text("OPENAI_API_KEY_1=file-key\n", encoding="utf-8") + + values = oracles.secret_environment(env_path, environ={"OPENAI_API_KEY_1": "process-key"}) + + assert values["OPENAI_API_KEY_1"] == "process-key" + + +def test_microsoft_check_requires_acknowledgement_before_configuration(tmp_clean_png: Path) -> None: + result = CliRunner().invoke( + oracles.cli, + [ + "check-microsoft", + str(tmp_clean_png), + "--content-uri", + "https://media.example.test/sample.png", + "--env-file", + "/does/not/exist", + ], + ) + + assert result.exit_code == 2 + assert "--acknowledge-upload" in result.output + assert "AZURE_CONTENT_SAFETY_ENDPOINT" not in result.output + + +def test_microsoft_check_uses_named_slot_and_dotenv_configuration( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + tmp_clean_png: Path, +) -> None: + slots_path = tmp_path / "slots.json" + slots_path.write_text( + json.dumps( + { + "format_version": 1, + "slots": [ + { + "name": "microsoft-api-1", + "surface": "microsoft-api", + "account_label": "content-safety-project", + "browser_profile": None, + "network_label": "direct", + } + ], + } + ), + encoding="utf-8", + ) + env_path = tmp_path / ".env" + env_path.write_text( + "AZURE_CONTENT_SAFETY_ENDPOINT=https://content-safety.example.test\n" + "AZURE_CONTENT_SAFETY_SUBSCRIPTION_ID=test-subscription\n" + "AZURE_CONTENT_SAFETY_RESOURCE_GROUP=test-resource-group\n" + "AZURE_CONTENT_SAFETY_ACCOUNT_NAME=test-content-safety\n" + "AZURE_ORACLE_STORAGE_RESOURCE_GROUP=test-storage-resource-group\n", + encoding="utf-8", + ) + calls: list[dict[str, object]] = [] + + def verify(path: Path, **kwargs: object) -> SimpleNamespace: + calls.append({"path": path, **kwargs}) + return SimpleNamespace(to_dict=lambda: {"status": "not_detected", "provenance_status": "absent"}) + + monkeypatch.setattr(oracles, "verify_microsoft", verify) + result = CliRunner().invoke( + oracles.cli, + [ + "check-microsoft", + str(tmp_clean_png), + "--content-uri", + "https://media.example.test/sample.png", + "--acknowledge-upload", + "--slots", + str(slots_path), + "--slot", + "microsoft-api-1", + "--env-file", + str(env_path), + ], + env={"AZURE_CONTENT_SAFETY_ENDPOINT": "", "AZURE_CONTENT_SAFETY_SUBSCRIPTION_ID": ""}, + ) + + assert result.exit_code == 0, result.output + assert calls == [ + { + "path": tmp_clean_png, + "content_uri": "https://media.example.test/sample.png", + "endpoint": "https://content-safety.example.test", + "subscription_id": "test-subscription", + "resource_group": "test-resource-group", + "account_name": "test-content-safety", + "storage_resource_group": "test-storage-resource-group", + "acknowledge_upload": True, + } + ] + payload = json.loads(result.output) + assert payload["status"] == "not_detected" + assert payload["execution_slot"]["name"] == "microsoft-api-1" + + +def test_check_at_is_timezone_aware(tmp_path: Path, tmp_clean_png: Path) -> None: + manifest_path = oracles.prepare_batch( + "gemini-web", + [tmp_clean_png], + output_dir=tmp_path / "oracle-batch", + repository_root=oracles.REPOSITORY_ROOT, + ) + artifact_id = json.loads(manifest_path.read_text(encoding="utf-8"))["rows"][0]["artifact_id"] + + with pytest.raises(ValueError, match="timezone-aware"): + oracles.record_result( + manifest_path, + artifact_id=artifact_id, + watermark_result="detected", + provenance_result="absent", + raw_response="SynthID detected", + checked_at="2026-09-07T18:30:00", + ) diff --git a/uv.lock b/uv.lock index d92292e..837bad3 100644 --- a/uv.lock +++ b/uv.lock @@ -1097,6 +1097,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/39/82/5c873dbb4fb001d22fbbd50e80d4c1b0181ddae106856132160f84b94e88/greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c", size = 633329, upload-time = "2026-08-10T14:30:06.062Z" }, + { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/f7df98e72a8eb4a7edfec5a08d6d1a4ab53a52c95ba0b2ea6c10b8dd9bd0/greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f", size = 428145, upload-time = "2026-08-10T14:30:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -2424,6 +2481,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/f6/4d85fc3a81cbb2969f485fe3dd165677574080a25f7160245cd4cae73998/pillow_heif-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6aa53c40d9aa1d7433b35823a8cd13463e057b2106919fec40cea14967548bd1", size = 6520559, upload-time = "2026-07-22T14:27:48.904Z" }, ] +[[package]] +name = "playwright" +version = "1.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/5b/ca2abcf3aa69f9fb510215e3064f30b57fe57657c8d04ede45bb966d5606/playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f", size = 43732091, upload-time = "2026-07-31T17:00:44.178Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/0bfbe9904350961f4dbb713f04342e40d548c5fc26c8157bd13617c81492/playwright-1.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034", size = 42510842, upload-time = "2026-07-31T17:00:48.596Z" }, + { url = "https://files.pythonhosted.org/packages/66/dc/c0486b407ad0699a250f6bbe3066fca95344009a99ca66e88ca175c69dc1/playwright-1.62.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:5108bd5b3e87169ddf269feee097da5893af7f8aea4634dfc840518d64c1f1da", size = 43732093, upload-time = "2026-07-31T17:00:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/43/6b/b24aebc2b04bffcb342bccf96e287c78b363e1615bed5cea97500cc0393a/playwright-1.62.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3", size = 47748926, upload-time = "2026-07-31T17:00:56.438Z" }, + { url = "https://files.pythonhosted.org/packages/36/43/b4b18bdc87e1949568fffdcde3ff9a0456266b2d0c6d4432cc34d89ea6eb/playwright-1.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2d76613a57ad844362ce42f7d0c2fa26b19a4f7a46d4f76b891c631e6e5aff", size = 47441423, upload-time = "2026-07-31T17:01:00.404Z" }, + { url = "https://files.pythonhosted.org/packages/81/22/af5d926fc2c32a339eec00a443644bc40ab9db1dd2dd9017873c59773c0c/playwright-1.62.0-py3-none-win32.whl", hash = "sha256:e5614fa89355d7081457680324bb219f79f69c423c5cb6fa250e30b0d8aebf1c", size = 38164450, upload-time = "2026-07-31T17:01:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a9/4160c1033c07af98bf841ad079457dd78408a5ee0dd56cbfe50b8b6a1c22/playwright-1.62.0-py3-none-win_amd64.whl", hash = "sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1", size = 38164458, upload-time = "2026-07-31T17:01:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ec/06b55d619a7082a766aa04f2c6bb31435c87f02930087d8a0517119408fa/playwright-1.62.0-py3-none-win_arm64.whl", hash = "sha256:ea8d3055aa9d5a9f1832ac82517bd8b42c78fac7ebcbebb0107116735c8cb6a1", size = 34208868, upload-time = "2026-07-31T17:01:11.818Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2801,6 +2877,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -3276,12 +3364,14 @@ dev = [ { name = "audioseal" }, { name = "av" }, { name = "huggingface-hub" }, + { name = "httpx" }, { name = "invisible-watermark" }, { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "openai" }, { name = "opencv-python-headless" }, { name = "packaging" }, + { name = "playwright" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -3381,6 +3471,7 @@ requires-dist = [ { name = "click", specifier = ">=8.0.0" }, { name = "diffsynth", marker = "extra == 'qwen-zimage'", specifier = ">=2.0.17,<3" }, { name = "diffusers", marker = "extra == 'diffusion'", specifier = ">=0.38.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28,<1" }, { name = "huggingface-hub", marker = "extra == 'classify'", specifier = ">=0.20.0" }, { name = "huggingface-hub", marker = "extra == 'dev'", specifier = ">=0.20.0" }, { name = "huggingface-hub", marker = "extra == 'lama'", specifier = ">=0.20.0" }, @@ -3399,6 +3490,7 @@ requires-dist = [ { name = "piexif", specifier = ">=1.1.3" }, { name = "pillow", specifier = ">=10.0.0" }, { name = "pillow-heif", marker = "extra == 'heif'", specifier = ">=0.13.0" }, + { name = "playwright", marker = "extra == 'dev'", specifier = ">=1.62,<2" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },