Unified · Split
| @@ -0,0 +1,29 @@ | |||
| 1 | --- | ||
| 2 | "executor": minor | ||
| 3 | --- | ||
| 4 | |||
| 5 | Resolved credentials are `Redacted<string>` end to end, and the unwrap points | ||
| 6 | are now lint-enforced. A new `executor/no-redacted-unwrap` rule flags every | ||
| 7 | `Redacted.value`, so each one is either an allowlisted boundary carrying a | ||
| 8 | `-- boundary:` reason or a bug. The allowlisted set is small and deliberate: the | ||
| 9 | provider serialization line, `renderAuthPlacements`, the oauth4webapi call | ||
| 10 | boundary, the `oauth_session.pkce_verifier` column, the MCP SDK's | ||
| 11 | `OAuthClientProvider`, the API-key one-time display, and the wire codecs. | ||
| 12 | |||
| 13 | Breaking for plugin authors. `CredentialProvider.get` returns | ||
| 14 | `Redacted<string> | null`, and `ToolInvocationCredential`'s `value` / `values` | ||
| 15 | entries are `Redacted<string> | null`. Inputs are widened rather than narrowed — | ||
| 16 | `set`, `setDefault`, and the OAuth secret inputs all accept | ||
| 17 | `string | Redacted<string>` — so existing writes keep compiling. Tool authoring | ||
| 18 | schemas are untouched: `Redacted` never appears on a tool's input or output. | ||
| 19 | |||
| 20 | A plugin that reads a credential directly must unwrap at the point the value | ||
| 21 | goes on the wire. A missed unwrap does not throw — it serializes the literal | ||
| 22 | `"<redacted>"` — so cover write paths with a test that asserts the persisted | ||
| 23 | bytes. | ||
| 24 | |||
| 25 | Also fixes two required secrets that silently defaulted to the empty string: the | ||
| 26 | billing route now returns 503 `billing_not_configured` when `AUTUMN_SECRET_KEY` | ||
| 27 | is unset instead of calling Autumn with an empty key, and the cloud plugin | ||
| 28 | config no longer hands the WorkOS Vault plugin empty credentials — omitting them | ||
| 29 | lets the plugin fail at startup, where a missing binding belongs. | ||
| @@ -22,6 +22,7 @@ · 21 unmodified lines | |||
| 22 | "executor/no-promise-reject": "error", | 22 | "executor/no-promise-reject": "error", |
| 23 | "executor/no-raw-durable-object-id": "error", | 23 | "executor/no-raw-durable-object-id": "error", |
| 24 | "executor/no-raw-fetch": "error", | 24 | "executor/no-raw-fetch": "error", |
| 25 | "executor/no-redacted-unwrap": "error", | ||
| 25 | "executor/no-redundant-primitive-cast": "error", | 26 | "executor/no-redundant-primitive-cast": "error", |
| 26 | "executor/no-redundant-error-factory": "error", | 27 | "executor/no-redundant-error-factory": "error", |
| 27 | "executor/no-schema-class": "error", | 28 | "executor/no-schema-class": "error", |
| @@ -155,8 +155,12 @@ export default defineExecutorConfig({ · 154 unmodified lines | |||
| 155 | allowPrivateGitHosts: allowLocalNetwork === true, | 155 | allowPrivateGitHosts: allowLocalNetwork === true, |
| 156 | }), | 156 | }), |
| 157 | toolkitsPlugin({ activeToolkitSlug }), | 157 | toolkitsPlugin({ activeToolkitSlug }), |
| 158 | // Neither credentials nor an injected client is a misconfiguration, not a | ||
| 159 | // mode: passing empty ones would build a vault client that 401s on the | ||
| 160 | // first credential read. Omitting both lets the plugin fail at startup, | ||
| 161 | // which is where a missing WORKOS_* binding belongs. | ||
| 158 | workosVaultPlugin({ | 162 | workosVaultPlugin({ |
| 159 | credentials: workosCredentials ?? { apiKey: "", clientId: "" }, | 163 | ...(workosCredentials ? { credentials: workosCredentials } : {}), |
| 160 | ...(workosVaultClient ? { client: workosVaultClient } : {}), | 164 | ...(workosVaultClient ? { client: workosVaultClient } : {}), |
| 161 | }), | 165 | }), |
| 162 | ] as const, | 166 | ] as const, |
| @@ -45,4 +45,5 @@ export const bearerCredential = (request: Request): BearerCredential => { · 44 unmodified lines | |||
| 45 | * dispatch on. Reads the plaintext to count segments and retains none of it, | 45 | * dispatch on. Reads the plaintext to count segments and retains none of it, |
| 46 | * so the call sites stay `Redacted`-only. */ | 46 | * so the call sites stay `Redacted`-only. */ |
| 47 | export const isJwtBearer = (token: Redacted.Redacted<string>): boolean => | 47 | export const isJwtBearer = (token: Redacted.Redacted<string>): boolean => |
| 48 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the shape of the credential is the dispatch key; only the segment count escapes | ||
| 48 | Redacted.value(token).split(".").length === 3; | 49 | Redacted.value(token).split(".").length === 3; |
| @@ -27,6 +27,7 @@ export const SessionAuthLive = Layer.effect( · 26 unmodified lines | |||
| 27 | cookie: (httpEffect, { credential }) => | 27 | cookie: (httpEffect, { credential }) => |
| 28 | Effect.gen(function* () { | 28 | Effect.gen(function* () { |
| 29 | const result = yield* workos | 29 | const result = yield* workos |
| 30 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the WorkOS SDK unseals the cookie itself and takes the sealed string | ||
| 30 | .authenticateSealedSession(Redacted.value(credential)) | 31 | .authenticateSealedSession(Redacted.value(credential)) |
| 31 | .pipe(Effect.orElseSucceed(() => null)); | 32 | .pipe(Effect.orElseSucceed(() => null)); |
| 32 | 33 | ||
| @@ -52,6 +53,7 @@ export const SessionAuthLive = Layer.effect( · 19 unmodified lines | |||
| 52 | }, | 53 | }, |
| 53 | }; | 54 | }; |
| 54 | 55 | ||
| 56 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: `Session.sealedSession` is re-set as a Set-Cookie header, so it holds the sealed string | ||
| 55 | const session = sessionFromSealed(result, Redacted.value(credential)); | 57 | const session = sessionFromSealed(result, Redacted.value(credential)); |
| 56 | const response = yield* httpEffect.pipe( | 58 | const response = yield* httpEffect.pipe( |
| 57 | Effect.provideService(SessionContext, session), | 59 | Effect.provideService(SessionContext, session), |
| @@ -74,6 +76,7 @@ export const OrgAuthLive = Layer.effect( · 16 unmodified lines | |||
| 74 | cookie: (httpEffect, { credential }) => | 76 | cookie: (httpEffect, { credential }) => |
| 75 | Effect.gen(function* () { | 77 | Effect.gen(function* () { |
| 76 | const result = yield* workos | 78 | const result = yield* workos |
| 79 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the WorkOS SDK unseals the cookie itself and takes the sealed string | ||
| 77 | .authenticateSealedSession(Redacted.value(credential)) | 80 | .authenticateSealedSession(Redacted.value(credential)) |
| 78 | .pipe(Effect.orElseSucceed(() => null)); | 81 | .pipe(Effect.orElseSucceed(() => null)); |
| 79 | 82 | ||
| @@ -85,6 +88,7 @@ export const OrgAuthLive = Layer.effect( · 5 unmodified lines | |||
| 85 | return yield* Effect.fail(new NoOrganization()); | 88 | return yield* Effect.fail(new NoOrganization()); |
| 86 | } | 89 | } |
| 87 | 90 | ||
| 91 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: `Session.sealedSession` is re-set as a Set-Cookie header, so it holds the sealed string | ||
| 88 | const session = sessionFromSealed(result, Redacted.value(credential)); | 92 | const session = sessionFromSealed(result, Redacted.value(credential)); |
| 89 | const auth = { | 93 | const auth = { |
| 90 | accountId: session.accountId, | 94 | accountId: session.accountId, |
| @@ -112,6 +112,7 @@ const resolveJwtPrincipal = (token: Redacted.Redacted<string>, jwt: JwtBearerCon · 111 unmodified lines | |||
| 112 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 113 | // Unwrapped for the signature check itself — jose verifies the token's own | 113 | // Unwrapped for the signature check itself — jose verifies the token's own |
| 114 | // bytes; nothing derived from them is retained. | 114 | // bytes; nothing derived from them is retained. |
| 115 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: jose verifies the token's own bytes | ||
| 115 | const verified = yield* verifyWorkosUserManagementToken(Redacted.value(token), jwt.jwks).pipe( | 116 | const verified = yield* verifyWorkosUserManagementToken(Redacted.value(token), jwt.jwks).pipe( |
| 116 | Effect.catchTag("McpJwtVerificationError", (error) => | 117 | Effect.catchTag("McpJwtVerificationError", (error) => |
| 117 | Effect.fail( | 118 | Effect.fail( |
| @@ -521,6 +521,7 @@ const make = Effect.gen(function* () { · 520 unmodified lines | |||
| 521 | */ | 521 | */ |
| 522 | validateApiKey: (value: Redacted.Redacted<string>) => | 522 | validateApiKey: (value: Redacted.Redacted<string>) => |
| 523 | use( | 523 | use( |
| 524 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: WorkOS has to receive the caller's actual key to answer whether it is valid | ||
| 524 | (wos) => wos.apiKeys.validateApiKey({ value: Redacted.value(value) }) as Promise<unknown>, | 525 | (wos) => wos.apiKeys.validateApiKey({ value: Redacted.value(value) }) as Promise<unknown>, |
| 525 | ), | 526 | ), |
| 526 | 527 | ||
| @@ -76,6 +76,20 @@ const handler = Effect.gen(function* () { · 75 unmodified lines | |||
| 76 | ) | 76 | ) |
| 77 | : undefined; | 77 | : undefined; |
| 78 | 78 | ||
| 79 | // An empty secret key is not a spelling of "no billing backend": autumn-js | ||
| 80 | // would accept it and every upstream call would 401, which surfaces as a | ||
| 81 | // billing failure rather than the misconfiguration it is. `AutumnService` | ||
| 82 | // degrades to a no-op tracker on the same missing key; this route has no | ||
| 83 | // no-op mode, so it fails loudly instead. | ||
| 84 | const secretKey = env.AUTUMN_SECRET_KEY; | ||
| 85 | if (!secretKey) { | ||
| 86 | return yield* new HttpResponseError({ | ||
| 87 | status: 503, | ||
| 88 | code: "billing_not_configured", | ||
| 89 | message: "Billing is not configured", | ||
| 90 | }); | ||
| 91 | } | ||
| 92 | |||
| 79 | const { statusCode, response } = yield* Effect.promise(() => | 93 | const { statusCode, response } = yield* Effect.promise(() => |
| 80 | autumnHandler({ | 94 | autumnHandler({ |
| 81 | request: { | 95 | request: { |
| @@ -89,7 +103,7 @@ const handler = Effect.gen(function* () { · 7 unmodified lines | |||
| 89 | email: session.email, | 103 | email: session.email, |
| 90 | }, | 104 | }, |
| 91 | clientOptions: { | 105 | clientOptions: { |
| 92 | secretKey: env.AUTUMN_SECRET_KEY ?? "", | 106 | secretKey, |
| 93 | // autumn-js's handler reads `baseURL` to override the Autumn endpoint | 107 | // autumn-js's handler reads `baseURL` to override the Autumn endpoint |
| 94 | // (not `serverURL`, which it silently ignores). Without this, a non-prod | 108 | // (not `serverURL`, which it silently ignores). Without this, a non-prod |
| 95 | // AUTUMN_API_URL (the e2e emulator, a self-hosted Autumn) is dropped and | 109 | // AUTUMN_API_URL (the e2e emulator, a self-hosted Autumn) is dropped and |
| @@ -179,6 +179,7 @@ export class McpOrganizationAuth extends Context.Service< · 178 unmodified lines | |||
| 179 | // Unwrapped for the signature check itself — jose verifies the token's own | 179 | // Unwrapped for the signature check itself — jose verifies the token's own |
| 180 | // bytes; nothing derived from them is retained. | 180 | // bytes; nothing derived from them is retained. |
| 181 | const verifyJwt = (token: Redacted.Redacted<string>) => | 181 | const verifyJwt = (token: Redacted.Redacted<string>) => |
| 182 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: jose verifies the token's own bytes | ||
| 182 | verifyWorkOSMcpAccessToken(Redacted.value(token), jwks, { | 183 | verifyWorkOSMcpAccessToken(Redacted.value(token), jwks, { |
| 183 | issuer: AUTHKIT_DOMAIN, | 184 | issuer: AUTHKIT_DOMAIN, |
| 184 | audience: WORKOS_CLIENT_ID, | 185 | audience: WORKOS_CLIENT_ID, |
| @@ -676,6 +676,7 @@ const providerGet = async ( · 675 unmodified lines | |||
| 676 | // The migration re-persists the value under its v2 id, so it needs the | 676 | // The migration re-persists the value under its v2 id, so it needs the |
| 677 | // plaintext here and nowhere else. | 677 | // plaintext here and nowhere else. |
| 678 | const value = await Effect.runPromise(oldProvider.get(secretId as never)); | 678 | const value = await Effect.runPromise(oldProvider.get(secretId as never)); |
| 679 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the v1 plaintext is re-persisted under its v2 id | ||
| 679 | return value === null ? null : Redacted.value(value); | 680 | return value === null ? null : Redacted.value(value); |
| 680 | } | 681 | } |
| 681 | return null; | 682 | return null; |
| @@ -96,6 +96,7 @@ export const CreateApiKeyBody = Schema.Struct({ · 95 unmodified lines | |||
| 96 | const OneTimeSecret = Schema.String.pipe( | 96 | const OneTimeSecret = Schema.String.pipe( |
| 97 | Schema.decodeTo(Schema.Redacted(Schema.String), { | 97 | Schema.decodeTo(Schema.Redacted(Schema.String), { |
| 98 | decode: SchemaGetter.transform(Redacted.make<string>), | 98 | decode: SchemaGetter.transform(Redacted.make<string>), |
| 99 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the encode side of the wire codec; the transport is the only place it is bare | ||
| 99 | encode: SchemaGetter.transform(Redacted.value<string>), | 100 | encode: SchemaGetter.transform(Redacted.value<string>), |
| 100 | }), | 101 | }), |
| 101 | ); | 102 | ); |
| @@ -103,6 +103,7 @@ const UpdateConnectionPayload = Schema.Struct({ · 102 unmodified lines | |||
| 103 | const PastedSecret = Schema.String.pipe( | 103 | const PastedSecret = Schema.String.pipe( |
| 104 | Schema.decodeTo(Schema.Redacted(Schema.String), { | 104 | Schema.decodeTo(Schema.Redacted(Schema.String), { |
| 105 | decode: SchemaGetter.transform(Redacted.make<string>), | 105 | decode: SchemaGetter.transform(Redacted.make<string>), |
| 106 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the encode side of the wire codec; the transport is the only place it is bare | ||
| 106 | encode: SchemaGetter.transform(Redacted.value<string>), | 107 | encode: SchemaGetter.transform(Redacted.value<string>), |
| 107 | }), | 108 | }), |
| 108 | ); | 109 | ); |
| @@ -41,6 +41,7 @@ export const makeCredentialScrubber = ( · 40 unmodified lines | |||
| 41 | ): CredentialScrubber => { | 41 | ): CredentialScrubber => { |
| 42 | const secrets = Object.values(values) | 42 | const secrets = Object.values(values) |
| 43 | .filter(Predicate.isNotNull) | 43 | .filter(Predicate.isNotNull) |
| 44 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the plaintext is the needle to strip out; it is only ever compared, never emitted | ||
| 44 | .map(Redacted.value) | 45 | .map(Redacted.value) |
| 45 | .filter((secret) => secret.length > 0) | 46 | .filter((secret) => secret.length > 0) |
| 46 | .sort((a, b) => b.length - a.length); | 47 | .sort((a, b) => b.length - a.length); |
| @@ -92,6 +92,7 @@ const renderPlacementValue = ( · 91 unmodified lines | |||
| 92 | if (placement.literal !== undefined) return placement.literal; | 92 | if (placement.literal !== undefined) return placement.literal; |
| 93 | const wrapped = values[placement.variable ?? TOKEN_VARIABLE]; | 93 | const wrapped = values[placement.variable ?? TOKEN_VARIABLE]; |
| 94 | if (wrapped == null) return null; | 94 | if (wrapped == null) return null; |
| 95 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: THE unwrap line for HTTP plugins; the string produced here goes straight onto the outbound request | ||
| 95 | const value = Redacted.value(wrapped); | 96 | const value = Redacted.value(wrapped); |
| 96 | return placement.prefix ? `${placement.prefix}${value}` : value; | 97 | return placement.prefix ? `${placement.prefix}${value}` : value; |
| 97 | }; | 98 | }; |
| @@ -87,6 +87,7 @@ export function oauthClientSecretFromInput( · 86 unmodified lines | |||
| 87 | value: string | Redacted.Redacted<string> | null | undefined, | 87 | value: string | Redacted.Redacted<string> | null | undefined, |
| 88 | ): string | Redacted.Redacted<string> | null { | 88 | ): string | Redacted.Redacted<string> | null { |
| 89 | if (value == null) return null; | 89 | if (value == null) return null; |
| 90 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: only the length is read; the wrapper is what is returned | ||
| 90 | const literal = Redacted.isRedacted(value) ? Redacted.value(value) : value; | 91 | const literal = Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 91 | return literal.length === 0 ? null : value; | 92 | return literal.length === 0 ? null : value; |
| 92 | } | 93 | } |
| @@ -125,6 +125,7 @@ export type DynamicClientMetadata = { · 124 unmodified lines | |||
| 125 | const RedactedString = Schema.String.pipe( | 125 | const RedactedString = Schema.String.pipe( |
| 126 | Schema.decodeTo(Schema.Redacted(Schema.String), { | 126 | Schema.decodeTo(Schema.Redacted(Schema.String), { |
| 127 | decode: SchemaGetter.transform(Redacted.make<string>), | 127 | decode: SchemaGetter.transform(Redacted.make<string>), |
| 128 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the encode side of the DCR client-information codec, which round-trips through the cache | ||
| 128 | encode: SchemaGetter.transform(Redacted.value<string>), | 129 | encode: SchemaGetter.transform(Redacted.value<string>), |
| 129 | }), | 130 | }), |
| 130 | ); | 131 | ); |
| @@ -542,9 +543,9 @@ export const registerDynamicClient = ( · 411 unmodified lines | |||
| 542 | accept: "application/json", | 543 | accept: "application/json", |
| 543 | }; | 544 | }; |
| 544 | if (input.initialAccessToken != null) { | 545 | if (input.initialAccessToken != null) { |
| 545 | // Boundary: the RFC 7591 initial access token goes out as a bearer header. | ||
| 546 | const token = Redacted.isRedacted(input.initialAccessToken) | 546 | const token = Redacted.isRedacted(input.initialAccessToken) |
| 547 | ? Redacted.value(input.initialAccessToken) | 547 | ? // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the RFC 7591 initial access token goes out as a bearer header |
| 548 | Redacted.value(input.initialAccessToken) | ||
| 548 | : input.initialAccessToken; | 549 | : input.initialAccessToken; |
| 549 | if (token.length > 0) headers.authorization = `Bearer ${token}`; | 550 | if (token.length > 0) headers.authorization = `Bearer ${token}`; |
| 550 | } | 551 | } |
| @@ -50,6 +50,7 @@ export type OAuth2SecretInput = string | Redacted.Redacted<string>; · 49 unmodified lines | |||
| 50 | /** Unwrap at an allowlisted boundary: `oauth4webapi` takes bare strings and puts | 50 | /** Unwrap at an allowlisted boundary: `oauth4webapi` takes bare strings and puts |
| 51 | * them on the wire. Every call site is a form field or an HTTP header. */ | 51 | * them on the wire. Every call site is a form field or an HTTP header. */ |
| 52 | const secretToSend = (value: OAuth2SecretInput): string => | 52 | const secretToSend = (value: OAuth2SecretInput): string => |
| 53 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: `oauth4webapi` takes bare strings; every call site is a form field or an HTTP header | ||
| 53 | Redacted.isRedacted(value) ? Redacted.value(value) : value; | 54 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 54 | 55 | ||
| 55 | export type OAuth2TokenResponse = { | 56 | export type OAuth2TokenResponse = { |
| @@ -614,7 +614,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 613 unmodified lines | |||
| 614 | if ( | 614 | if ( |
| 615 | input.clientSecret != null && | 615 | input.clientSecret != null && |
| 616 | (Redacted.isRedacted(input.clientSecret) | 616 | (Redacted.isRedacted(input.clientSecret) |
| 617 | ? Redacted.value(input.clientSecret) | 617 | ? // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: emptiness check only; every `Redacted` is truthy so the wrapper cannot answer it |
| 618 | Redacted.value(input.clientSecret) | ||
| 618 | : input.clientSecret) === "" | 619 | : input.clientSecret) === "" |
| 619 | ) { | 620 | ) { |
| 620 | return yield* new StorageError({ | 621 | return yield* new StorageError({ |
| @@ -1050,7 +1051,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 429 unmodified lines | |||
| 1050 | ProviderItemId.make(String(row.client_secret_item_id)), | 1051 | ProviderItemId.make(String(row.client_secret_item_id)), |
| 1051 | ); | 1052 | ); |
| 1052 | clientSecret = | 1053 | clientSecret = |
| 1053 | stored !== null && oauthClientSecretFromInput(Redacted.value(stored)) !== null | 1054 | stored !== null && |
| 1055 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: emptiness check only; the wrapper is what is assigned | ||
| 1056 | oauthClientSecretFromInput(Redacted.value(stored)) !== null | ||
| 1054 | ? stored | 1057 | ? stored |
| 1055 | : null; | 1058 | : null; |
| 1056 | } | 1059 | } |
| @@ -1196,6 +1199,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 139 unmodified lines | |||
| 1196 | // the authorize URL, and returned to the caller so `complete` can quote | 1199 | // the authorize URL, and returned to the caller so `complete` can quote |
| 1197 | // it. It cannot stay wrapped, so the unwrap happens here at the branded-id | 1200 | // it. It cannot stay wrapped, so the unwrap happens here at the branded-id |
| 1198 | // constructor rather than being spread across those call sites. | 1201 | // constructor rather than being spread across those call sites. |
| 1202 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: `OAuthState` is a correlation key matched by equality and echoed in the authorize URL | ||
| 1199 | const state = OAuthState.make(Redacted.value(createOAuthState())); | 1203 | const state = OAuthState.make(Redacted.value(createOAuthState())); |
| 1200 | const providerState = encodeOAuthCallbackState({ | 1204 | const providerState = encodeOAuthCallbackState({ |
| 1201 | state: String(state), | 1205 | state: String(state), |
| @@ -1219,6 +1223,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 17 unmodified lines | |||
| 1219 | // "<redacted>", so a missed unwrap here would silently store that | 1223 | // "<redacted>", so a missed unwrap here would silently store that |
| 1220 | // literal and every later `complete` would fail PKCE validation. | 1224 | // literal and every later `complete` would fail PKCE validation. |
| 1221 | // (The column itself is plaintext; encrypting it is a separate change.) | 1225 | // (The column itself is plaintext; encrypting it is a separate change.) |
| 1226 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the `oauth_session.pkce_verifier` persistence line | ||
| 1222 | pkce_verifier: Redacted.value(verifier), | 1227 | pkce_verifier: Redacted.value(verifier), |
| 1223 | identity_label: input.identityLabel ?? null, | 1228 | identity_label: input.identityLabel ?? null, |
| 1224 | // Persist the requested scope set (declared ∪ client, filtered to the | 1229 | // Persist the requested scope set (declared ∪ client, filtered to the |
| @@ -54,4 +54,5 @@ export interface CredentialProvider { · 53 unmodified lines | |||
| 54 | * "<redacted>", so a value that reaches a backend still wrapped is persisted as | 54 | * "<redacted>", so a value that reaches a backend still wrapped is persisted as |
| 55 | * that literal instead of failing. */ | 55 | * that literal instead of failing. */ |
| 56 | export const credentialValueToWrite = (value: string | Redacted.Redacted<string>): string => | 56 | export const credentialValueToWrite = (value: string | Redacted.Redacted<string>): string => |
| 57 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: THE serialization line every provider backend's `set` writes through | ||
| 57 | Redacted.isRedacted(value) ? Redacted.value(value) : value; | 58 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| @@ -262,6 +262,7 @@ const gitTokenFor = ( · 261 unmodified lines | |||
| 262 | ProviderKey.make(config.tokenProvider), | 262 | ProviderKey.make(config.tokenProvider), |
| 263 | ProviderItemId.make(config.tokenItemId), | 263 | ProviderItemId.make(config.tokenItemId), |
| 264 | ); | 264 | ); |
| 265 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the git source API takes a bare bearer token | ||
| 265 | return token === null ? null : Redacted.value(token); | 266 | return token === null ? null : Redacted.value(token); |
| 266 | } | 267 | } |
| 267 | return null; | 268 | return null; |
| @@ -517,6 +517,7 @@ const makeOAuthProvider = (accessToken: Redacted.Redacted<string>): OAuthClientP · 516 unmodified lines | |||
| 517 | // to the transport's Authorization header and its type is a plain string, so | 517 | // to the transport's Authorization header and its type is a plain string, so |
| 518 | // the token must be unwrapped to cross into third-party code. Kept inside the | 518 | // the token must be unwrapped to cross into third-party code. Kept inside the |
| 519 | // accessor so the unwrapped value exists only for the duration of a dial. | 519 | // accessor so the unwrapped value exists only for the duration of a dial. |
| 520 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the MCP SDK's OAuthClientProvider hands `access_token` straight to the transport's Authorization header | ||
| 520 | tokens: () => ({ access_token: Redacted.value(accessToken), token_type: "Bearer" }), | 521 | tokens: () => ({ access_token: Redacted.value(accessToken), token_type: "Bearer" }), |
| 521 | saveTokens: () => undefined, | 522 | saveTokens: () => undefined, |
| 522 | redirectToAuthorization: async () => { | 523 | redirectToAuthorization: async () => { |
| @@ -585,6 +586,7 @@ const buildConnectorInput = ( · 62 unmodified lines | |||
| 585 | const value = values[variable]; | 586 | const value = values[variable]; |
| 586 | // Boundary: the child process's environment block is a wire format — | 587 | // Boundary: the child process's environment block is a wire format — |
| 587 | // it takes strings. This is the stdio twin of `renderPlacementValue`. | 588 | // it takes strings. This is the stdio twin of `renderPlacementValue`. |
| 589 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the child process's environment block is a wire format | ||
| 588 | if (value != null) env[variable] = Redacted.value(value); | 590 | if (value != null) env[variable] = Redacted.value(value); |
| 589 | } | 591 | } |
| 590 | } | 592 | } |
| @@ -653,6 +655,7 @@ const credentialIdentity = ( · 62 unmodified lines | |||
| 653 | Object.fromEntries( | 655 | Object.fromEntries( |
| 654 | Object.entries(values).map(([variable, value]) => [ | 656 | Object.entries(values).map(([variable, value]) => [ |
| 655 | variable, | 657 | variable, |
| 658 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the pool key must differ when the credential differs; it never leaves this process | ||
| 656 | value === null ? null : Redacted.value(value), | 659 | value === null ? null : Redacted.value(value), |
| 657 | ]), | 660 | ]), |
| 658 | ), | 661 | ), |
| @@ -679,6 +679,7 @@ export const invokeOpenApiBackedTool = (input: { · 678 unmodified lines | |||
| 679 | if (template) { | 679 | if (template) { |
| 680 | const missing = requiredTemplateVariables(template).filter((name) => { | 680 | const missing = requiredTemplateVariables(template).filter((name) => { |
| 681 | const value = input.credential.values[name]; | 681 | const value = input.credential.values[name]; |
| 682 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: emptiness check only; `Redacted.make("")` is truthy so the wrapper cannot answer it | ||
| 682 | return value == null || Redacted.value(value) === ""; | 683 | return value == null || Redacted.value(value) === ""; |
| 683 | }); | 684 | }); |
| 684 | if (missing.length > 0) { | 685 | if (missing.length > 0) { |
| @@ -933,6 +934,7 @@ export const checkHealthOpenApi = (input: { · 248 unmodified lines | |||
| 933 | if (template) { | 934 | if (template) { |
| 934 | const missing = requiredTemplateVariables(template).filter((name) => { | 935 | const missing = requiredTemplateVariables(template).filter((name) => { |
| 935 | const value = input.credential.values[name]; | 936 | const value = input.credential.values[name]; |
| 937 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: emptiness check only; `Redacted.make("")` is truthy so the wrapper cannot answer it | ||
| 936 | return value == null || Redacted.value(value) === ""; | 938 | return value == null || Redacted.value(value) === ""; |
| 937 | }); | 939 | }); |
| 938 | if (missing.length > 0) { | 940 | if (missing.length > 0) { |
| @@ -106,6 +106,7 @@ export function ApiKeysPage() { · 105 unmodified lines | |||
| 106 | // create dialog, and the user cannot copy a "<redacted>" rendering. Scoped to | 106 | // create dialog, and the user cannot copy a "<redacted>" rendering. Scoped to |
| 107 | // the dialog's lifetime — `closeCreate` drops `createdKey`, so the plaintext | 107 | // the dialog's lifetime — `closeCreate` drops `createdKey`, so the plaintext |
| 108 | // is gone as soon as the dialog closes. | 108 | // is gone as soon as the dialog closes. |
| 109 | // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the one-time display is the whole point of the create dialog; scoped to its lifetime | ||
| 109 | const createdKeyPlaintext = createdKey ? Redacted.value(createdKey.value) : null; | 110 | const createdKeyPlaintext = createdKey ? Redacted.value(createdKey.value) : null; |
| 110 | 111 | ||
| 111 | const closeCreate = (open: boolean) => { | 112 | const closeCreate = (open: boolean) => { |
| @@ -18,6 +18,7 @@ import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js · 17 unmodified lines | |||
| 18 | import noRawDurableObjectId from "./oxlint-plugin-executor/rules/no-raw-durable-object-id.js"; | 18 | import noRawDurableObjectId from "./oxlint-plugin-executor/rules/no-raw-durable-object-id.js"; |
| 19 | import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js"; | 19 | import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js"; |
| 20 | import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js"; | 20 | import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js"; |
| 21 | import noRedactedUnwrap from "./oxlint-plugin-executor/rules/no-redacted-unwrap.js"; | ||
| 21 | import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js"; | 22 | import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js"; |
| 22 | import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js"; | 23 | import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js"; |
| 23 | import noSchemaClass from "./oxlint-plugin-executor/rules/no-schema-class.js"; | 24 | import noSchemaClass from "./oxlint-plugin-executor/rules/no-schema-class.js"; |
| @@ -62,6 +63,7 @@ export default { · 38 unmodified lines | |||
| 62 | "no-raw-durable-object-id": noRawDurableObjectId, | 63 | "no-raw-durable-object-id": noRawDurableObjectId, |
| 63 | "no-raw-fetch": noRawFetch, | 64 | "no-raw-fetch": noRawFetch, |
| 64 | "no-raw-error-throw": noRawErrorThrow, | 65 | "no-raw-error-throw": noRawErrorThrow, |
| 66 | "no-redacted-unwrap": noRedactedUnwrap, | ||
| 65 | "no-redundant-primitive-cast": noRedundantPrimitiveCast, | 67 | "no-redundant-primitive-cast": noRedundantPrimitiveCast, |
| 66 | "no-redundant-error-factory": noRedundantErrorFactory, | 68 | "no-redundant-error-factory": noRedundantErrorFactory, |
| 67 | "no-schema-class": noSchemaClass, | 69 | "no-schema-class": noSchemaClass, |
| @@ -0,0 +1,39 @@ | |||
| 1 | import { | ||
| 2 | getPropertyName, | ||
| 3 | isDeclarationFile, | ||
| 4 | isIdentifier, | ||
| 5 | isTestLike, | ||
| 6 | unwrapExpression, | ||
| 7 | } from "../utils.js"; | ||
| 8 | |||
| 9 | const message = | ||
| 10 | 'Unwrapping a credential is a boundary decision, not a convenience. Keep the value `Redacted` and let the wire/persistence line unwrap it; if this IS that line, mark it: `// oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: <why the plaintext has to cross here>`. A missed unwrap on a write path does not throw — `Redacted`\'s toJSON renders the literal "<redacted>" and it is persisted instead.'; | ||
| 11 | |||
| 12 | // Matches the reference too, not just the call: `.map(Redacted.value)` and | ||
| 13 | // `SchemaGetter.transform(Redacted.value<string>)` unwrap exactly as much as an | ||
| 14 | // applied call does. | ||
| 15 | const isRedactedUnwrap = (node) => | ||
| 16 | isIdentifier(unwrapExpression(node.object), "Redacted") && | ||
| 17 | getPropertyName(node.property) === "value"; | ||
| 18 | |||
| 19 | export default { | ||
| 20 | meta: { | ||
| 21 | type: "problem", | ||
| 22 | docs: { | ||
| 23 | description: "Disallow Redacted.value outside allowlisted credential boundaries.", | ||
| 24 | }, | ||
| 25 | }, | ||
| 26 | create(context) { | ||
| 27 | // Tests unwrap to assert the bytes that actually reach a backend — the | ||
| 28 | // assertion that catches a missed unwrap in the first place. | ||
| 29 | if (isTestLike(context.filename) || isDeclarationFile(context.filename)) return {}; | ||
| 30 | |||
| 31 | return { | ||
| 32 | MemberExpression(node) { | ||
| 33 | if (isRedactedUnwrap(node)) { | ||
| 34 | context.report({ node, message }); | ||
| 35 | } | ||
| 36 | }, | ||
| 37 | }; | ||
| 38 | }, | ||
| 39 | }; | ||