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 | 22 | "executor/no-promise-reject": "error", |
| 23 | 23 | "executor/no-raw-durable-object-id": "error", |
| 24 | 24 | "executor/no-raw-fetch": "error", |
| 25 | + "executor/no-redacted-unwrap": "error", | |
| 25 | 26 | "executor/no-redundant-primitive-cast": "error", |
| 26 | 27 | "executor/no-redundant-error-factory": "error", |
| 27 | 28 | "executor/no-schema-class": "error", |
| @@ -155,8 +155,12 @@ export default defineExecutorConfig({ · 154 unmodified lines | ||
| 155 | 155 | allowPrivateGitHosts: allowLocalNetwork === true, |
| 156 | 156 | }), |
| 157 | 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 | 162 | workosVaultPlugin({ |
| 159 | − credentials: workosCredentials ?? { apiKey: "", clientId: "" }, | |
| 163 | + ...(workosCredentials ? { credentials: workosCredentials } : {}), | |
| 160 | 164 | ...(workosVaultClient ? { client: workosVaultClient } : {}), |
| 161 | 165 | }), |
| 162 | 166 | ] as const, |
| @@ -45,4 +45,5 @@ export const bearerCredential = (request: Request): BearerCredential => { · 44 unmodified lines | ||
| 45 | 45 | * dispatch on. Reads the plaintext to count segments and retains none of it, |
| 46 | 46 | * so the call sites stay `Redacted`-only. */ |
| 47 | 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 | 49 | Redacted.value(token).split(".").length === 3; |
| @@ -27,6 +27,7 @@ export const SessionAuthLive = Layer.effect( · 26 unmodified lines | ||
| 27 | 27 | cookie: (httpEffect, { credential }) => |
| 28 | 28 | Effect.gen(function* () { |
| 29 | 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 | 31 | .authenticateSealedSession(Redacted.value(credential)) |
| 31 | 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 | 57 | const session = sessionFromSealed(result, Redacted.value(credential)); |
| 56 | 58 | const response = yield* httpEffect.pipe( |
| 57 | 59 | Effect.provideService(SessionContext, session), |
| @@ -74,6 +76,7 @@ export const OrgAuthLive = Layer.effect( · 16 unmodified lines | ||
| 74 | 76 | cookie: (httpEffect, { credential }) => |
| 75 | 77 | Effect.gen(function* () { |
| 76 | 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 | 80 | .authenticateSealedSession(Redacted.value(credential)) |
| 78 | 81 | .pipe(Effect.orElseSucceed(() => null)); |
| 79 | 82 | |
| @@ -85,6 +88,7 @@ export const OrgAuthLive = Layer.effect( · 5 unmodified lines | ||
| 85 | 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 | 92 | const session = sessionFromSealed(result, Redacted.value(credential)); |
| 89 | 93 | const auth = { |
| 90 | 94 | accountId: session.accountId, |
| @@ -112,6 +112,7 @@ const resolveJwtPrincipal = (token: Redacted.Redacted<string>, jwt: JwtBearerCon · 111 unmodified lines | ||
| 112 | 112 | Effect.gen(function* () { |
| 113 | 113 | // Unwrapped for the signature check itself — jose verifies the token's own |
| 114 | 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 | 116 | const verified = yield* verifyWorkosUserManagementToken(Redacted.value(token), jwt.jwks).pipe( |
| 116 | 117 | Effect.catchTag("McpJwtVerificationError", (error) => |
| 117 | 118 | Effect.fail( |
| @@ -521,6 +521,7 @@ const make = Effect.gen(function* () { · 520 unmodified lines | ||
| 521 | 521 | */ |
| 522 | 522 | validateApiKey: (value: Redacted.Redacted<string>) => |
| 523 | 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 | 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 | 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 | 93 | const { statusCode, response } = yield* Effect.promise(() => |
| 80 | 94 | autumnHandler({ |
| 81 | 95 | request: { |
| @@ -89,7 +103,7 @@ const handler = Effect.gen(function* () { · 7 unmodified lines | ||
| 89 | 103 | email: session.email, |
| 90 | 104 | }, |
| 91 | 105 | clientOptions: { |
| 92 | − secretKey: env.AUTUMN_SECRET_KEY ?? "", | |
| 106 | + secretKey, | |
| 93 | 107 | // autumn-js's handler reads `baseURL` to override the Autumn endpoint |
| 94 | 108 | // (not `serverURL`, which it silently ignores). Without this, a non-prod |
| 95 | 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 | 179 | // Unwrapped for the signature check itself — jose verifies the token's own |
| 180 | 180 | // bytes; nothing derived from them is retained. |
| 181 | 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 | 183 | verifyWorkOSMcpAccessToken(Redacted.value(token), jwks, { |
| 183 | 184 | issuer: AUTHKIT_DOMAIN, |
| 184 | 185 | audience: WORKOS_CLIENT_ID, |
| @@ -676,6 +676,7 @@ const providerGet = async ( · 675 unmodified lines | ||
| 676 | 676 | // The migration re-persists the value under its v2 id, so it needs the |
| 677 | 677 | // plaintext here and nowhere else. |
| 678 | 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 | 680 | return value === null ? null : Redacted.value(value); |
| 680 | 681 | } |
| 681 | 682 | return null; |
| @@ -96,6 +96,7 @@ export const CreateApiKeyBody = Schema.Struct({ · 95 unmodified lines | ||
| 96 | 96 | const OneTimeSecret = Schema.String.pipe( |
| 97 | 97 | Schema.decodeTo(Schema.Redacted(Schema.String), { |
| 98 | 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 | 100 | encode: SchemaGetter.transform(Redacted.value<string>), |
| 100 | 101 | }), |
| 101 | 102 | ); |
| @@ -103,6 +103,7 @@ const UpdateConnectionPayload = Schema.Struct({ · 102 unmodified lines | ||
| 103 | 103 | const PastedSecret = Schema.String.pipe( |
| 104 | 104 | Schema.decodeTo(Schema.Redacted(Schema.String), { |
| 105 | 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 | 107 | encode: SchemaGetter.transform(Redacted.value<string>), |
| 107 | 108 | }), |
| 108 | 109 | ); |
| @@ -41,6 +41,7 @@ export const makeCredentialScrubber = ( · 40 unmodified lines | ||
| 41 | 41 | ): CredentialScrubber => { |
| 42 | 42 | const secrets = Object.values(values) |
| 43 | 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 | 45 | .map(Redacted.value) |
| 45 | 46 | .filter((secret) => secret.length > 0) |
| 46 | 47 | .sort((a, b) => b.length - a.length); |
| @@ -92,6 +92,7 @@ const renderPlacementValue = ( · 91 unmodified lines | ||
| 92 | 92 | if (placement.literal !== undefined) return placement.literal; |
| 93 | 93 | const wrapped = values[placement.variable ?? TOKEN_VARIABLE]; |
| 94 | 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 | 96 | const value = Redacted.value(wrapped); |
| 96 | 97 | return placement.prefix ? `${placement.prefix}${value}` : value; |
| 97 | 98 | }; |
| @@ -87,6 +87,7 @@ export function oauthClientSecretFromInput( · 86 unmodified lines | ||
| 87 | 87 | value: string | Redacted.Redacted<string> | null | undefined, |
| 88 | 88 | ): string | Redacted.Redacted<string> | null { |
| 89 | 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 | 91 | const literal = Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 91 | 92 | return literal.length === 0 ? null : value; |
| 92 | 93 | } |
| @@ -125,6 +125,7 @@ export type DynamicClientMetadata = { · 124 unmodified lines | ||
| 125 | 125 | const RedactedString = Schema.String.pipe( |
| 126 | 126 | Schema.decodeTo(Schema.Redacted(Schema.String), { |
| 127 | 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 | 129 | encode: SchemaGetter.transform(Redacted.value<string>), |
| 129 | 130 | }), |
| 130 | 131 | ); |
| @@ -542,9 +543,9 @@ export const registerDynamicClient = ( · 411 unmodified lines | ||
| 542 | 543 | accept: "application/json", |
| 543 | 544 | }; |
| 544 | 545 | if (input.initialAccessToken != null) { |
| 545 | − // Boundary: the RFC 7591 initial access token goes out as a bearer header. | |
| 546 | 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 | 549 | : input.initialAccessToken; |
| 549 | 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 | 50 | /** Unwrap at an allowlisted boundary: `oauth4webapi` takes bare strings and puts |
| 51 | 51 | * them on the wire. Every call site is a form field or an HTTP header. */ |
| 52 | 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 | 54 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 54 | 55 | |
| 55 | 56 | export type OAuth2TokenResponse = { |
| @@ -614,7 +614,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 613 unmodified lines | ||
| 614 | 614 | if ( |
| 615 | 615 | input.clientSecret != null && |
| 616 | 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 | 619 | : input.clientSecret) === "" |
| 619 | 620 | ) { |
| 620 | 621 | return yield* new StorageError({ |
| @@ -1050,7 +1051,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 429 unmodified lines | ||
| 1050 | 1051 | ProviderItemId.make(String(row.client_secret_item_id)), |
| 1051 | 1052 | ); |
| 1052 | 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 | 1057 | ? stored |
| 1055 | 1058 | : null; |
| 1056 | 1059 | } |
| @@ -1196,6 +1199,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 139 unmodified lines | ||
| 1196 | 1199 | // the authorize URL, and returned to the caller so `complete` can quote |
| 1197 | 1200 | // it. It cannot stay wrapped, so the unwrap happens here at the branded-id |
| 1198 | 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 | 1203 | const state = OAuthState.make(Redacted.value(createOAuthState())); |
| 1200 | 1204 | const providerState = encodeOAuthCallbackState({ |
| 1201 | 1205 | state: String(state), |
| @@ -1219,6 +1223,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 17 unmodified lines | ||
| 1219 | 1223 | // "<redacted>", so a missed unwrap here would silently store that |
| 1220 | 1224 | // literal and every later `complete` would fail PKCE validation. |
| 1221 | 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 | 1227 | pkce_verifier: Redacted.value(verifier), |
| 1223 | 1228 | identity_label: input.identityLabel ?? null, |
| 1224 | 1229 | // Persist the requested scope set (declared ∪ client, filtered to the |
| @@ -54,4 +54,5 @@ export interface CredentialProvider { · 53 unmodified lines | ||
| 54 | 54 | * "<redacted>", so a value that reaches a backend still wrapped is persisted as |
| 55 | 55 | * that literal instead of failing. */ |
| 56 | 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 | 58 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| @@ -262,6 +262,7 @@ const gitTokenFor = ( · 261 unmodified lines | ||
| 262 | 262 | ProviderKey.make(config.tokenProvider), |
| 263 | 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 | 266 | return token === null ? null : Redacted.value(token); |
| 266 | 267 | } |
| 267 | 268 | return null; |
| @@ -517,6 +517,7 @@ const makeOAuthProvider = (accessToken: Redacted.Redacted<string>): OAuthClientP · 516 unmodified lines | ||
| 517 | 517 | // to the transport's Authorization header and its type is a plain string, so |
| 518 | 518 | // the token must be unwrapped to cross into third-party code. Kept inside the |
| 519 | 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 | 521 | tokens: () => ({ access_token: Redacted.value(accessToken), token_type: "Bearer" }), |
| 521 | 522 | saveTokens: () => undefined, |
| 522 | 523 | redirectToAuthorization: async () => { |
| @@ -585,6 +586,7 @@ const buildConnectorInput = ( · 62 unmodified lines | ||
| 585 | 586 | const value = values[variable]; |
| 586 | 587 | // Boundary: the child process's environment block is a wire format — |
| 587 | 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 | 590 | if (value != null) env[variable] = Redacted.value(value); |
| 589 | 591 | } |
| 590 | 592 | } |
| @@ -653,6 +655,7 @@ const credentialIdentity = ( · 62 unmodified lines | ||
| 653 | 655 | Object.fromEntries( |
| 654 | 656 | Object.entries(values).map(([variable, value]) => [ |
| 655 | 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 | 659 | value === null ? null : Redacted.value(value), |
| 657 | 660 | ]), |
| 658 | 661 | ), |
| @@ -679,6 +679,7 @@ export const invokeOpenApiBackedTool = (input: { · 678 unmodified lines | ||
| 679 | 679 | if (template) { |
| 680 | 680 | const missing = requiredTemplateVariables(template).filter((name) => { |
| 681 | 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 | 683 | return value == null || Redacted.value(value) === ""; |
| 683 | 684 | }); |
| 684 | 685 | if (missing.length > 0) { |
| @@ -933,6 +934,7 @@ export const checkHealthOpenApi = (input: { · 248 unmodified lines | ||
| 933 | 934 | if (template) { |
| 934 | 935 | const missing = requiredTemplateVariables(template).filter((name) => { |
| 935 | 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 | 938 | return value == null || Redacted.value(value) === ""; |
| 937 | 939 | }); |
| 938 | 940 | if (missing.length > 0) { |
| @@ -106,6 +106,7 @@ export function ApiKeysPage() { · 105 unmodified lines | ||
| 106 | 106 | // create dialog, and the user cannot copy a "<redacted>" rendering. Scoped to |
| 107 | 107 | // the dialog's lifetime — `closeCreate` drops `createdKey`, so the plaintext |
| 108 | 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 | 110 | const createdKeyPlaintext = createdKey ? Redacted.value(createdKey.value) : null; |
| 110 | 111 | |
| 111 | 112 | const closeCreate = (open: boolean) => { |
| @@ -18,6 +18,7 @@ import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js · 17 unmodified lines | ||
| 18 | 18 | import noRawDurableObjectId from "./oxlint-plugin-executor/rules/no-raw-durable-object-id.js"; |
| 19 | 19 | import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js"; |
| 20 | 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 | 22 | import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js"; |
| 22 | 23 | import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js"; |
| 23 | 24 | import noSchemaClass from "./oxlint-plugin-executor/rules/no-schema-class.js"; |
| @@ -62,6 +63,7 @@ export default { · 38 unmodified lines | ||
| 62 | 63 | "no-raw-durable-object-id": noRawDurableObjectId, |
| 63 | 64 | "no-raw-fetch": noRawFetch, |
| 64 | 65 | "no-raw-error-throw": noRawErrorThrow, |
| 66 | + "no-redacted-unwrap": noRedactedUnwrap, | |
| 65 | 67 | "no-redundant-primitive-cast": noRedundantPrimitiveCast, |
| 66 | 68 | "no-redundant-error-factory": noRedundantErrorFactory, |
| 67 | 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 | +}; | |