Skip to content

fix(sdk): redact plugin and extension source credentials at the serialization boundary (#3752)#3786

Open
haya-asif wants to merge 3 commits into
OpenHands:mainfrom
haya-asif:fix/redact-plugin-source-credentials
Open

fix(sdk): redact plugin and extension source credentials at the serialization boundary (#3752)#3786
haya-asif wants to merge 3 commits into
OpenHands:mainfrom
haya-asif:fix/redact-plugin-source-credentials

Conversation

@haya-asif

@haya-asif haya-asif commented Jun 18, 2026

Copy link
Copy Markdown

HUMAN:

This PR fixes a credential leak where plugin/extension source URLs containing embedded tokens (e.g.https://oauth2:TOKEN@host/repo.git) were written verbatim to persisted state (.installed.json, conversation state) and logs. The fix redacts them at the model serialization boundary while keeping the raw value in memorynfor fetching. I reviewed the change and ran all test successfully.


AGENT:

Why

Closes #3752.

Credential-bearing plugin/extension source URLs (e.g.
https://oauth2:TOKEN@github.com/org/repo.git) reached persisted state
(.installed.json, conversation state) and logs through the
plugin/extension source path. Redaction existed only as a manual call inside
ResolvedPluginSource.from_plugin_source, so any other model_dump() /
model_dump_json() of a plugin source emitted raw credentials.

This is the SDK-side sibling of OpenHands/OpenHands#12959 (fixed there by adding
a @field_serializer('source') to that repo's PluginSpec); the equivalent
choke point was never added to the SDK's own models. Per the #2196 review, the
durable fix is a single redaction boundary on the model (a serializer) plus
explicit redaction at the log sites that bypass serialization — not
per-call-site scrubbing.

Summary

  • Add @field_serializer("source") to PluginSource, ResolvedPluginSource,
    and InstallationInfo, so credentials are redacted on every model_dump /
    model_dump_json (including nested and persisted dumps). The raw value is
    kept in memory so the plugin can still be fetched/cloned.
  • Consolidate redaction onto the serializer: from_plugin_source now stores the
    source verbatim instead of pre-redacting it, so the serializer is the single
    source of truth.
  • Redact spec.source in the local_conversation plugin-load debug log
    (f-strings bypass serializers), matching the existing plugin/loader.py site.

Maps to the acceptance criteria in #3752:

  • PluginSource redacts source via @field_serializer (covers subclasses + nested dumps).
  • Manual redaction in ResolvedPluginSource.from_plugin_source consolidated onto the serializer.
  • InstallationInfo.source redacted before being persisted to .installed.json.
  • local_conversation plugin-load log redacts spec.source.
  • Audit of other plugin/extension-source sites: plugin/loader.py already redacts; the extensions/fetch.py error strings only ever emit github: / local / unparseable forms (a credentialed https://…@… URL is classified as GIT before reaching them), so no raw credentials leak there.
  • Tests exercise the live paths (serialize a model, persist .installed.json, the plugin-load log), not just the redaction regex.

Issue Number

Closes #3752.

How to Test

All commands run from the repo root.

1. Reproduce the leak before the fix (run on main, before this change):

PluginSource(source="https://oauth2:SECRET@github.com/org/repo.git").model_dump_json()
# -> contains "SECRET"   (site 1: PluginSource)        LEAK
InstallationInfo(name="x", source="https://oauth2:SECRET@...", install_path=...).model_dump_json()
# -> contains "SECRET"   (site 2: InstallationInfo)    LEAK

2. Verify the fix (this branch) — credentials redacted on dump, raw kept in memory, non-credential forms unchanged, and the live .installed.json write redacted:

PluginSource dump_json redacted: True
PluginSource keeps raw in memory: True
  unchanged on dump [github:owner/repo]: True
  unchanged on dump [/local/path]: True
  unchanged on dump [git@github.com:o/r.git]: True
ResolvedPluginSource dump_json redacted: True   (raw in memory: True)
InstallationInfo dump_json redacted: True
persisted .installed.json redacted: True

3. Tests — added live-path regression tests; updated two existing tests that asserted construction-time (in-memory) redaction to assert serialization-time redaction.

# Targeted + live-path tests
uv run pytest tests/sdk/git/test_url_redaction.py \
  tests/sdk/extensions/installation \
  tests/sdk/conversation/test_local_conversation_plugins.py tests/sdk/plugin
# -> 193 passed

# Broader regression (serialization / state persistence)
uv run pytest -n auto tests/sdk/conversation tests/sdk/extensions
# -> 699 passed

4. Lint + types

uv run ruff check <changed files>          # All checks passed!
uv run ruff format --check <changed files> # already formatted
uv run pyright <changed files>             # 0 errors, 0 warnings

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

Behavior change worth a reviewer's eye: ResolvedPluginSource.source and the
in-memory plugin specs now keep the raw URL (redaction moved to the
serialization boundary). Persisted state and logs are redacted exactly as
before; on resume the redacted source plus the resolved commit SHA re-fetch from
the local cache, unchanged. The two updated tests in test_url_redaction.py
reflect this (serialization-time rather than construction-time redaction).

Out of scope (tracked separately): git/workspace command-layer redaction
(#3751) and GitCommandError.command (#3689).

@haya-asif haya-asif marked this pull request as ready for review June 18, 2026 11:37

@WaseemSabir WaseemSabir left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

lgtm!

@haya-asif

Copy link
Copy Markdown
Author

This PR is ready for review and implements the fix tracked in #3752 — redacting plugin/extension source credentials at the model serialization boundary (with the raw value kept in memory for fetching). It follows the acceptance criteria in the issue and passes the SDK test suite, ruff, and pyright locally.

The pull_request workflows are currently held at "Approve and run workflows" (first-time contributor). Could a maintainer kick off the CI runs and take a look when you get a chance? Happy to address any feedback — thanks!

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.

Redact plugin/extension source credentials at a single serialization choke point (follow-up to #2196 review, sibling of OpenHands#12959)

2 participants