feat: strip plugin fields marked as secret before model_dump()#405
feat: strip plugin fields marked as secret before model_dump()#405red40maxxer wants to merge 1 commit into
model_dump()#405Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces functionality to strip sensitive plugin settings (typed with Pydantic's Secret, SecretStr, or SecretBytes) from serialized outputs like config.toml and reproduce.json. The review feedback points out a critical issue where secret fields wrapped in Union, Optional, or Annotated types are not correctly identified, potentially leading to credential leaks. Additionally, the feedback addresses style guide violations regarding the use of abbreviations and comment formatting.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| for name, field in model.model_fields.items(): | ||
| annotation = field.annotation | ||
| is_secret_type = isinstance(annotation, type) and issubclass( | ||
| annotation, (Secret, SecretStr, SecretBytes) | ||
| ) | ||
| if is_secret_type: | ||
| names.add(name) |
There was a problem hiding this comment.
The current implementation of secret_field_names only checks if field.annotation is a direct subclass of Secret, SecretStr, or SecretBytes. However, if a field is optional (e.g., Optional[SecretStr] or SecretStr | None) or annotated (e.g., Annotated[SecretStr, Field(...)]), field.annotation will be a generic alias or union type rather than a direct subclass. This causes secret fields to be silently ignored and not stripped, potentially leaking sensitive credentials.
We should recursively unwrap the type annotation to handle Union, Optional, and Annotated types correctly.
| for name, field in model.model_fields.items(): | |
| annotation = field.annotation | |
| is_secret_type = isinstance(annotation, type) and issubclass( | |
| annotation, (Secret, SecretStr, SecretBytes) | |
| ) | |
| if is_secret_type: | |
| names.add(name) | |
| from typing import get_origin, get_args, Annotated, Union | |
| import types | |
| def is_secret_type(tp: Any) -> bool: | |
| if tp is None: | |
| return False | |
| origin = get_origin(tp) | |
| if origin is Annotated: | |
| return is_secret_type(get_args(tp)[0]) | |
| if origin in (Union, getattr(types, "UnionType", None)): | |
| return any(is_secret_type(arg) for arg in get_args(tp)) | |
| base_type = origin if origin is not None else tp | |
| return isinstance(base_type, type) and issubclass( | |
| base_type, (Secret, SecretStr, SecretBytes) | |
| ) | |
| for name, field in model.model_fields.items(): | |
| if is_secret_type(field.annotation): | |
| names.add(name) |
| for cfg in settings.scorers: | ||
| plugin_cls = load_plugin(cfg.plugin, Scorer) | ||
| model = plugin_cls.get_settings_model() | ||
| if model is None: | ||
| continue | ||
|
|
||
| secrets = secret_field_names(model) | ||
| if not secrets: | ||
| continue | ||
|
|
||
| class_name = plugin_cls.__name__ | ||
| candidates = [class_name] | ||
| # Kind of ugly, needs a second thought here | ||
| if cfg.instance_name: | ||
| candidates.append(f"{class_name}_{cfg.instance_name}") | ||
|
|
||
| for table_name in candidates: | ||
| table = scorer_tables.get(table_name) | ||
| if isinstance(table, dict): | ||
| for field in secrets: | ||
| # TODO: think about removing vs placeholder | ||
| table.pop(field, None) |
There was a problem hiding this comment.
This block contains a few style guide violations:
- The identifier
cfgis an abbreviation, which violates the repository style guide rule against abbreviations. We should rename it toscorer_config. - The comment on line 95 (
# Kind of ugly, needs a second thought here) and the comment on line 103 (# TODO: think about removing vs placeholder) do not end with a period, and the latter does not start with a capital letter.
Let's refactor this block to resolve these style guide violations.
for scorer_config in settings.scorers:
plugin_cls = load_plugin(scorer_config.plugin, Scorer)
model = plugin_cls.get_settings_model()
if model is None:
continue
secrets = secret_field_names(model)
if not secrets:
continue
class_name = plugin_cls.__name__
candidates = [class_name]
# This can be refactored to be cleaner in a future iteration.
if scorer_config.instance_name:
candidates.append(f"{class_name}_{scorer_config.instance_name}")
for table_name in candidates:
table = scorer_tables.get(table_name)
if isinstance(table, dict):
for field in secrets:
# TODO: Think about removing vs placeholder.
table.pop(field, None)| # TODO: we might have to warn the user here that this might not be directly reproducible, | ||
| # as the plugin contains secret fields that will be omitted from the final `reproduce.json`. |
There was a problem hiding this comment.
The comment here does not start with a capital letter and does not end with a period, which violates the repository style guide. Let's capitalize the first word and add a period at the end.
| # TODO: we might have to warn the user here that this might not be directly reproducible, | |
| # as the plugin contains secret fields that will be omitted from the final `reproduce.json`. | |
| # TODO: We might have to warn the user here that this might not be directly reproducible, | |
| # as the plugin contains secret fields that will be omitted from the final `reproduce.json`. |
References
- Comments should start with a capital letter and end with a period. They should use correct grammar and spelling. (link)
|
I faced a similar problem when designing the headless mode, and I came to this conclusion: Lines 1062 to 1069 in c8a254b Relying on persistence exclusion mechanisms is always brittle. The industry standard for handling secrets is environment variables, and all LLM client libraries support them out of the box. The only secure solution is to not store such things in configuration files at all, because even if the reproducibility mode takes care of it somehow, people might still share config files directly etc. |
|
I also thought about this for the upcoming logger plugins, where we will support pushing to Weights and Biases. The solution is again the same: The official |
|
As for the real problem of plugins potentially leaking sensitive information (not just API secrets, but also details like local paths): I propose that plugins have to opt in to reproducibility. They need to set a flag to true in order to be serialized, and if any loaded plugins don't have that flag set, we can't reproduce. Deciding whether to set this flag is the responsibility of the plugin author, and it is documented what this entails (serialization + publishing). |
I think this can be done cleanly by making it a class-level property same as |
|
Agreed, this should be a property of the plugin itself, not a configuration. The author needs to explicitly set the As explained above, I don't think Heretic should have any special handling for secrets. It just creates a false sense of security when we don't really intend to provide strong guarantees, and all relevant libraries are able to handle secrets through external mechanisms. |
I'm implementing an LLM refusal judge plugin that uses an OAI-compatible API, and obviously the API key shouldn't included in the
reproduce.json. Pydantic already provides a way to mark fields as secret withSecretStrandSecretBytes, but we can't just use these in the settings fields directly.model_dump()dumps the parentSettingswhich will include themodel_extrathat contains our raw, untyped scorer settings, so Pydantic can't automatically redact it (since there's no type information). Also, if there was a typedSecretStrfield onSettings,tomli_wgets handed an unserializableSecretStrobject and errorsStill a WIP, there are many other design decisions to figure out but happy to have this roasted, this is probably going to be a hard requirement as I forsee lots of people making LLM judge plugins and leaking their API keys everywhere