Skip to content

Bind credentials to their endpoints and strike echoed tokens - #2111

Open
ppXD wants to merge 1 commit into
mainfrom
fix/bind-model-endpoints-to-their-keys
Open

ppXD wants to merge 1 commit into
mainfrom
fix/bind-model-endpoints-to-their-keys

Conversation

@ppXD

@ppXD ppXD commented Oct 12, 2026

Copy link
Copy Markdown
Owner

Bind credentials to their endpoints and strike echoed tokens

Summary

  • Mask credentials embedded in URLs on every list. This covers userinfo and secret-named query values in model-credential BaseUrl and git-provider BaseUrl/ApiUrl/WebUrl.
    • CredentialUrl.KeepStoredIfMasked reads a masked value sent back as "unchanged", so edit forms and API clients that PUT or PATCH back what they GET keep the stored URL.
    • A *** left in an otherwise edited URL is refused instead of being stored as the credential (ModelCredentialService, ProviderInstanceService).
  • Bind stored secrets to their endpoint. Moving a URL's scheme, host or port (CredentialUrl.SameEndpoint) now has these effects:
    • Model credential: the key must be sent again in the same update (400 model_endpoint_key_required).
    • Git provider, tokens connected: moving the base or API host is refused while non-revoked tokens are connected through it (428 provider_endpoint_credentials_bound). Those tokens are not the editor's to re-enter, so the remedy is to disconnect them first or add the new host as its own provider.
    • Git provider, OAuth client secret stored: the secret must be sent again, because the OAuth token and revoke URLs are derived from the base URL (400 provider_endpoint_secret_required).
    • Audit log: every move is logged with scheme://host:port only.
  • Strike echoed credentials before anything stores or shows them.
    • LlmHttpTransport error bodies, plus an interaction.failed backstop.
    • The ExternalCallResilience SDK translation, which also drops the Original call: <url> suffix.
    • The provider calls that read refusal bodies themselves: GitLab raw /unapprove, GitLab project and group hook create/list, and GitHub repo and org hook create. Their output feeds external_call.failed, repository_webhook.last_error and *_webhook_attempt.error/response_body, all of which any team member can read.
  • One shared rule for spotting credentials in text and URLs. It lives in Services/Redaction (CredentialNeedles, CredentialUrl); the agent-run redactor's behaviour is unchanged.
  • Frontend:
    • The model and provider edit forms ask for the key or OAuth secret again on an endpoint move.
    • They explain masked URLs.
    • They say why a provider host move is refused while tokens are connected.

Test plan

  • Unit:
    • Full suite: 13554 passed, 1 skipped.
    • ProviderRefusalEchoTests (GitLab /unapprove 401/403/500 and the scope hint; GitLab project and group hook create/list; GitHub repo and org hook create).
    • ProviderInstanceEndpointBindingTests.
    • The node-level external_call.failed test in NodeObservabilityTests.
    • CredentialNeedles.Strike, CredentialUrl.KeepStoredIfMasked, and the failure-code pins.
  • Integration:
    • WebhookRegistrationCredentialEchoTests: the real registrars and providers against an echoing stub; no token in last_error, attempt.error or attempt.response_body.
    • ProviderInstanceEndpointBindingFlowTests: a refused repoint changes nothing; revoked tokens and path-only edits are allowed; the OAuth secret is required on a host move; masked values round-trip.
    • The provider, webhook, credential, OAuth, model-credential and authorization namespaces all pass.
  • E2E:
    • ProviderInstanceRepointE2ETests: an Admin PATCH of baseUrl or apiUrl gets 428, the attacker stub accepts 0 connections, and a Viewer's branch read still reaches the real host. A Viewer's provider list contains no canary, and a masked PATCH keeps the stored URLs.
    • Existing ModelKeyExposureE2ETests and GitProviderErrorEchoE2ETests still pass.
  • Mutation: reverting each fix turns its tests red.
  • Frontend (Node 22): vitest on the edit forms and the credentialEndpoint helpers, pnpm lint (0 errors), tsc -b, pnpm build.

@ppXD
ppXD force-pushed the fix/close-node-record-redaction-gaps branch from 44372ce to 0bf1f84 Compare October 12, 2026 17:37
@ppXD
ppXD changed the base branch from fix/close-node-record-redaction-gaps to main October 12, 2026 17:39
A model credential's base URL can carry credentials (user:key@host,
?api-key=), and the list returned it verbatim to every member. So could
a git provider connection's base, API and web URLs. Both lists now mask
userinfo and secret-named query values; an update that sends the masked
value back keeps the stored URL, so existing edit forms and API clients
that PUT or PATCH back what they GET keep working. A masked placeholder
left in an otherwise edited URL is refused instead of being stored as
the credential.

Stored secrets are write-only, yet an Admin could repoint an endpoint
without re-entering them and have the next refresh, probe, run,
repository read, webhook registration or OAuth refresh present them to
the new host. Moving a model credential's scheme, host or port now needs
the key in the same update (400 model_endpoint_key_required). Moving a
git provider's base or API host is refused while member tokens are still
connected through it (428 provider_endpoint_credentials_bound: those
tokens are not the editor's to re-enter, so they are disconnected first
or the new host is added as its own provider), and needs its stored
OAuth client secret again (400 provider_endpoint_secret_required).
Path-only changes and connections with nothing bound are unaffected,
and every endpoint move is logged with scheme://host:port only.

A gateway that echoes the key it rejected put that key into node,
interaction and external-call failure records a Viewer can read.
LlmHttpTransport now strikes every credential the request presented
from the error body before LlmApiException exists (classification still
reads the raw body), and the interaction.failed payload strikes the
call's own credential as a backstop.

Git provider errors that echo the presented token reached Viewers on
repository pages, git node errors and webhook pages. The resilience
translation strikes the tokens the auth resolver handed the instance
and drops the SDK's "Original call: <url>" suffix from the client-facing
message and the insufficient-scope hint. The calls that read a refusal
body themselves - GitLab's raw /unapprove, the project and group hook
creates and lists, GitHub's hook creates - strike the token they sent
before the body becomes an exception message or a webhook attempt's
stored response.

The needle rule (secret-name markers, URL userinfo and query values)
moves to Services/Redaction so the agent-run redactor and these scrubs
share one definition; the agent-run redactor's needles are unchanged.
The model and provider edit forms ask for the secret again when the
endpoint moves and say why a provider move is refused.
@ppXD
ppXD force-pushed the fix/bind-model-endpoints-to-their-keys branch from 285dc86 to 270ce6c Compare October 12, 2026 17:39
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.

1 participant