Skip to content

FEAT: Restore saved registry entries and stay not ready if any fail (part 4) - #3909

Open
varunj-msft wants to merge 96 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/13178-Restore-Saved-Entries
Open

varunj-msft wants to merge 96 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/13178-Restore-Saved-Entries

Conversation

@varunj-msft

@varunj-msft varunj-msft commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Description

Part 4 of saving Registry entries across restarts. Stacked on part 3 (#3908); parts 5 (#3965) and 6 (#3986) build on it. This PR's own changes: varunj-msft/PyRIT@varunj-msft/13178-Rebuild-Saved-Entries...varunj-msft/13178-Restore-Saved-Entries

Design: 4.6 When a saved entry can't be restored, 4.2 Where recipes live. Where every part fits: PR map.

This is one of the changes for saving Registry entries across restarts. It builds on #3908. Until the earlier PRs merge, the diff here also shows their changes.

Problem. Saved entries need to come back at startup and after a live apply. A broken one must stop runs instead of vanishing; otherwise a scan that expects a saved converter or adversarial_chat would run with something else, or fail later.

Change.

  • New setting registry_recipes_source: a local folder or Azure Blob location, default ~/.pyrit/registry_recipes. It's validated like scenario_presets_source and documented in pyrit_conf.md and .pyrit_conf_example.
  • RuntimeLifecycle restores saved entries in two places. At startup, after the initializers run and the configuration version is read. On apply, after the new version is recorded; apply already resets the registries, so restore starts clean. The recipes are read in a worker thread, and the entries are built on the runtime loop, as the target API builds them, since a constructor such as HuggingFaceChatTarget's starts a task on that loop. As with the target API, a slow constructor holds the loop while it runs.
  • If anything fails, the runtime stays failed with outcome restore-failed and doesn't publish. The middleware already returns 503 for runtime routes, and /api/health shows ready: false. GET /api/config/runtime lists restore_failures and restore_ignored, and the failures are logged. For now the fix is to correct or delete the file and restart.
  • The backend unit tests, the analytics benchmark fixture and the offline browser fixture point the recipes folder at a temp folder, so they don't read the developer's own saved entries; the backend README says so for the browser fixture. The seeded Playwright backend and the end-to-end test backend still use the default folder. Nothing writes saved entries yet, so isolating them comes with the PRs that save entries.

What stays the same. With no saved entries, startup and apply behave as before. A file whose name isn't a valid entry name is listed as ignored and doesn't block readiness.

Next. Fixing or deleting a broken entry without a restart, and showing the failure list to every signed-in user rather than only in the admin runtime status, come with the recovery routes in #3965 (part 5).

Tests and Documentation

  • tests/unit/backend/test_runtime_lifecycle.py: startup and apply restore an entry that uses a target the startup scripts register, the recipes are read in a worker thread and restored entries are built on the runtime loop, startup and apply with a broken entry stay not ready and list it in the runtime status, ignored files don't block readiness, fixing the file and restarting reaches ready, cancellation still propagates, and a reference name or file name that isn't valid text still returns 200. tests/unit/setup/test_configuration_loader.py covers the new setting; tests/unit/backend/conftest.py, the analytics benchmark fixture and the offline browser fixture (frontend/e2e/fixtures/manual_send_backend.py) point the recipes folder at a temp folder, so they never read a developer's saved entries.
  • pytest tests/unit/backend tests/unit/setup tests/unit/registry tests/unit/models -n 4: 8,053 passed, 9 skipped, 4 xfailed
  • pre-commit run --from-ref origin/main --to-ref HEAD: passed
  • Ran the backend locally against a recipes folder with a target whose temperature can't be read, a pool that references a missing target, a very long value, and a file name that isn't valid UTF-8: /api/health showed ready: false, GET /api/config/runtime returned 200 with all three failures and the escaped file name, and neither the status nor the log held the test secret. After deleting the three broken files and restarting, it reached ready, with the oddly named file still listed as ignored.
  • Also ran it with a saved HuggingFaceChatTarget (offline, so only its background model load fails): it reached ready at startup and after a live apply. With the restore in a worker thread instead, startup failed with no running event loop.
  • Docs: doc/getting_started/pyrit_conf.md and .pyrit_conf_example describe registry_recipes_source. JupyText: not run (no notebooks changed).

…API input

External construction now rejects a parameter whose type includes Path unless the
calling service passes the name in allowed_path_params after making the value safe.
The converter service allows its upload parameters once they are stored, and the
target service allows the server-set upload folder and the GitHubCopilotTarget
working directory. The scorer-only check is removed, and the "Valid parameters"
list leaves out path parameters callers cannot set.
The target catalog now applies the path rule to required parameters too, so a
target that needs a disallowed path is left out instead of listed without the
field, matching the scorer catalog. allowed_path_params is typed frozenset[str]
so a bare string cannot turn the allow-list into a substring match.
The target catalog still lists a target whose required path parameter is its
server-set upload folder when the server has one, and leaves it out otherwise.
# Conflicts:
#	tests/unit/backend/test_converter_service.py
@richlundeen
Richard Lundeen (richlundeen) dismissed their stale review November 9, 2026 07:27

Withdrawing my approval for now so I can review this PR again.

self.restore_failures, self.restore_ignored = report.failures, report.ignored
if not report.failures:
return True
self.state, self.outcome = "failed", "restore-failed"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Design: one failed entry makes the API unavailable for everyone

Any restore failure leaves the runtime failed, and the runtime gate then returns 503 for every /api/ route except config, initializers, auth and health. Targets, attacks, scenarios, presets, converters and scorers are down for every user until the file is fixed or deleted. I agree with the goal: a scan must never quietly run without a saved entry it expects. But this ties the whole app's availability to the health of every saved file, including ones nothing uses.

Realistic ways to get here:

  • Upgrades. Moving a class to another module fails every saved entry of that class as module_mismatch (cls.__module__ != recipe.class_module). Removing or renaming a class fails them as unknown_class. PyRIT does move classes between releases, so a routine upgrade could come up not ready.
  • Rollbacks. After a rollback across a format change, entries saved by the newer version fail as newer_schema_version, so rolling back doesn't restore service either.
  • Shared deployments. One user's broken target takes the API down for everyone.
  • Storage hiccups. A Blob error on a single download becomes an unreadable_recipe failure, which blocks too.

Suggestion: stay ready, and fail closed only where a failed entry could change what runs.

  • Reserve the names of failed entries, so a lookup gets the restore failure rather than "not found" or a fallback.
  • Refuse a launch that would use a failed entry, by name or by tag. If tag matching is too hard to get right, refuse scenario launches while any restore failure exists, and keep everything else up, including the Retry and Delete that come in later PRs.
  • If some deployments want today's all-or-nothing behavior, make it an opt-in setting.

Related: diagnostics. When this does block, the operator needs the cause quickly. The restore code doesn't log anything, and the log line below records only kind/name: reason. The API message is the only detail, and _safe_message hides it entirely when it contains @ or %. Before this merges, please log each failure once per restore with its message and, when there is one, the exception with its traceback, with any SAS token redacted. The API text can stay sanitized as it is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Put the launch check in ScenarioRunService; a coarser rule is simpler

The code supports staying ready, as the #3908 review now plans. A missing saved entry can change what runs only in a scenario run: all, default, and tag selections skip a missing technique, and a missing adversarial_chat or objective_scorer_chat falls back to a plain OpenAIChatTarget (scenario_target_defaults.py:133-153). Only scenario code and scenario_run_service.py (lines 408, 456, 523) reach these paths. Other lookups by name already fail with "not found", except after a name_clash: there the startup entry keeps the name, and the failure list names the clash.

  • Put the check in ScenarioRunService.start_run_async and resume_run_async, not only in the routes. Suite launches go through start_run_async, and resume has no route check today (routes/scenarios.py:269).
  • Points 2 and 4 of that review keep launches that name their techniques running. They need a failed-names check in each resolver and point 3 for a folder that cannot be listed (that failure has no name), and a name_clash still leaves the startup entry under the name. A simpler rule is to refuse every scenario start and resume while restore_failures is not empty. It needs none of these changes. Its cost is that no scenario launch starts while any failure exists; the rest of the API stays up either way.
  • If a deployment must still stop on a failure, add a failure count to /api/health for the deploy check. deployment_common.sh:263 stops only on the states failed, restart-required, and stopping.
  • Where live apply is on (enable_live_reinitialization: true, off by default), apply then works as a retry after a fix, because it resets the registries and restores again (line 265). Today begin_apply refuses failed (line 204), but the GUI still offers Reinitialize in that state (Reinitialize.tsx:101).
  • Logging: log each failure's sanitized message now (line 158 logs only kind/name: reason), with a caplog test for one record per failure and no sig= value. For a failure with no name, the message, log, and docs must say that the source could not be read, not "fix or delete the files". The traceback needs FEAT: Rebuild saved registry entries in dependency order (part 3) #3908 to keep the exception (r4517083964); keep it out of RestoreFailure, because status() returns that model to the API.

Make this change here, because #3965 builds Retry and its banner on the failed state.

@varunj-msft varunj-msft changed the title FEAT: Restore saved registry entries and stay not ready if any fail FEAT: Restore saved registry entries and stay not ready if any fail (part 4) Nov 9, 2026
FileDocumentStorage now raises DocumentStorageUnavailableError when the
folder or container can't be used, and DocumentUnreadableError when one
document exists but can't be read. The message names the location, with
any SAS token hidden, and a short reason. One backend handler maps both to
503 and logs the location and reason, with the traceback at debug level,
so the per-route storage helpers are gone.

A single read, a conditional save and a delete now follow the listing's
rules: a folder, pipe or broken link named like a document is unreadable
rather than missing, and on Blob only a deleted blob counts as missing.
Blob sign-in, permission, network and missing-container errors stop the
listing instead of marking every file unreadable. Each unreadable report
keeps the real file name and the original error, and the listing's
documents are read-only.

Initializer delete removes a script that can't be read or decoded, and
registering one no longer turns a storage write failure into a 400.
Tests patch module-level aliases instead of os.
List recipes escapes the OS text it reports, such as a file name that
isn't UTF-8, and a recipe whose JSON holds an unpaired surrogate escape
is reported as invalid, so the models no longer need a JSON-only
serializer. Recipes may start with a byte-order mark. A document without
schema_version reads as version 1, and each file is checked against the
format version of the model that reads it. Validation summaries shorten
each location part and the whole message, since a location can repeat a
stored key.

Recipes and StoredRecipe are no longer frozen, since their params and
tags never were, and StoredRecipe checks that its recipe type matches its
registry. The listing's recipes are read-only, and it keeps the error
behind each recipe that couldn't be read, for the log.
Listing catches only storage errors, so a bug is raised instead of being
reported as unreadable storage, and a bug while planning is a build
error rather than an invalid recipe. Each failure is logged once, with
the error behind it and URL credentials removed. The report shows the
error's text with each URL's user name, query and fragment removed, any
SAS signature hidden, and the length capped.

A class that moved within PyRIT is restored; one now registered from
outside PyRIT still fails. A technique whose name or tag clashes is a
name clash, and anything else registration refuses is an invalid
recipe. A recipe that sets a server file or folder is refused before it
is built. Class metadata comes from the registry's cache. The class
docstring lists the steps the API's create runs that restore skips.
The runtime becomes ready even when some saved entries fail; the
failures stay in the runtime status. Each failed entry keeps its name in
its registry: looking it up, or taking the name, raises
SavedEntryUnavailableError, which the backend answers with 409, so
nothing else stands in for it. Administrators see the full message and
everyone else a fixed explanation of the reason. A folder that can't be
listed holds every unregistered name of its kind, and each entry it left
unbuilt is reported.

While a saved technique is held, a launch or estimate that picks
techniques by all, default or a tag is refused with 409 for scenarios
whose techniques come from the registry, since the broken technique's
tags may be unknown. Naming techniques still works; naming the held one
fails with its reason. Route catch-alls let the error through instead of
turning it into a 500.
registry_recipes_source: https://account.blob.core.windows.net/pyrit-storage/registry-recipes
```

Each kind has its own folder under the source (`targets/`, `converters/`, `scorers/`, `techniques/`), with one JSON file per saved entry named after the entry. A file holds what's needed to rebuild the entry; restore refuses a file that stores a value for a parameter its class marks as a credential, such as `api_key`. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/registry_recipes`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Warn that write access to this source gives administrator authority

Restore builds every file in this source at startup and after a live apply, but it skips the create API's administrator checks (target_service.py:464-469). A principal that can write a file here, but is not a backend administrator, can save a HuggingFaceChatTarget with trust_remote_code: true. When the server has the Hugging Face dependencies (torch and transformers) and a HUGGINGFACE_TOKEN, restore then loads the model with remote code under the backend identity. The same writer can save an AzureBlobStorageTarget without sas_token, so the target uses the server's credentials on a container that the writer chose. This paragraph mentions only the credential check, so an operator can think that these files are screened. Add a warning like the one for custom_initializers_source above, here and in .pyrit_conf_example: give write access only to principals that you trust as backend administrators, and use a dedicated folder or blob prefix. If building moves to the service create methods (#3908 comment), restore still needs an administrator context to rebuild entries that an administrator saved, because a recipe does not record who saved it. That context passes these checks, so the warning is still necessary.

Offline repro (model loader replaced, no network): a saved HuggingFaceChatTarget file with trust_remote_code: true restored with no failure, and the loader got trust_remote_code=True. create_target_async(..., is_admin=False) with the same params raised PermissionError.

targets = TargetRegistry.get_registry_singleton()
config = await runtime._load_async()
config.registry_recipes_source = str(recipes)
config.apply_prepared_reinitialization_async.side_effect = lambda **_: targets.instances.register(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P3] Test an apply that follows a startup restore

In production, a live apply runs only after startup restored every saved entry, so the registries already hold those entries. Only reset_setup_registries() in the real apply prevents a name_clash for each one. This stub skips that reset, and startup_chat starts with empty registries. If the reset stops clearing converters or scorers, no test fails, and every apply on a server with saved entries of that kind fails its restore. Other tests pin only the target and technique resets, and no test runs an apply after a startup restore. Restore the same recipes at startup first, and make this stub call reset_setup_registries() before it registers startup_chat. Repro: restoring a ROT13Converter recipe twice without a reset gives name_clash ("Instance 'rot' already exists"); after reset_setup_registries() it restores.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants