History

Lint-enforce Redacted unwrap allowlist

Rhys Sullivan committed 3 months ago

Commit f7a2a461 parent 1c65fc825 files · +124 −6Browse files

Unified · Split

25 files
  1. .changeset/olive-pandas-remain.md +29 −0
  2. .oxlintrc.jsonc +1 −0
  3. apps/cloud/executor.config.ts +5 −1
  4. apps/cloud/src/auth/bearer.ts +1 −0
  5. apps/cloud/src/auth/middleware-live.ts +4 −0
  6. apps/cloud/src/auth/workos-auth-provider.ts +1 −0
  7. apps/cloud/src/auth/workos.ts +1 −0
  8. apps/cloud/src/extensions/billing/route.ts +15 −1
  9. apps/cloud/src/mcp/auth.ts +1 −0
  10. apps/local/src/db/v1-v2-migration.ts +1 −0
  11. packages/core/api/src/account/api.ts +1 −0
  12. packages/core/api/src/connections/api.ts +1 −0
  13. packages/core/sdk/src/credential-scrub.ts +1 −0
  14. packages/core/sdk/src/http-auth/auth-method.ts +1 −0
  15. packages/core/sdk/src/oauth-client.ts +1 −0
  16. packages/core/sdk/src/oauth-discovery.ts +3 −2
  17. packages/core/sdk/src/oauth-helpers.ts +1 −0
  18. packages/core/sdk/src/oauth-service.ts +7 −2
  19. packages/core/sdk/src/provider.ts +1 −0
  20. packages/plugins/apps/src/plugin/apps-plugin.ts +1 −0
  21. packages/plugins/mcp/src/sdk/plugin.ts +3 −0
  22. packages/plugins/openapi/src/sdk/backing.ts +2 −0
  23. packages/react/src/pages/api-keys.tsx +1 −0
  24. scripts/oxlint-plugin-executor.js +2 −0
  25. scripts/oxlint-plugin-executor/rules/no-redacted-unwrap.js +39 −0
.changeset/olive-pandas-remain.md+29−0
@@ -0,0 +1,29 @@
1---
2"executor": minor
3---
4
5Resolved credentials are `Redacted<string>` end to end, and the unwrap points
6are 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
9provider serialization line, `renderAuthPlacements`, the oauth4webapi call
10boundary, the `oauth_session.pkce_verifier` column, the MCP SDK's
11`OAuthClientProvider`, the API-key one-time display, and the wire codecs.
12
13Breaking for plugin authors. `CredentialProvider.get` returns
14`Redacted<string> | null`, and `ToolInvocationCredential`'s `value` / `values`
15entries 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
18schemas are untouched: `Redacted` never appears on a tool's input or output.
19
20A plugin that reads a credential directly must unwrap at the point the value
21goes 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
23bytes.
24
25Also fixes two required secrets that silently defaulted to the empty string: the
26billing route now returns 503 `billing_not_configured` when `AUTUMN_SECRET_KEY`
27is unset instead of calling Autumn with an empty key, and the cloud plugin
28config no longer hands the WorkOS Vault plugin empty credentials — omitting them
29lets the plugin fail at startup, where a missing binding belongs.
.oxlintrc.jsonc+1−0
@@ -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",
apps/cloud/executor.config.ts+5−1
@@ -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,
apps/cloud/src/auth/bearer.ts+1−0
@@ -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. */
47export const isJwtBearer = (token: Redacted.Redacted<string>): boolean =>47export 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;
apps/cloud/src/auth/middleware-live.ts+4−0
@@ -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* workos29 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* workos78 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,
apps/cloud/src/auth/workos-auth-provider.ts+1−0
@@ -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 own113 // 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(
apps/cloud/src/auth/workos.ts+1−0
@@ -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
apps/cloud/src/extensions/billing/route.ts+15−1
@@ -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 endpoint107 // autumn-js's handler reads `baseURL` to override the Autumn endpoint
94 // (not `serverURL`, which it silently ignores). Without this, a non-prod108 // (not `serverURL`, which it silently ignores). Without this, a non-prod
95 // AUTUMN_API_URL (the e2e emulator, a self-hosted Autumn) is dropped and109 // AUTUMN_API_URL (the e2e emulator, a self-hosted Autumn) is dropped and
apps/cloud/src/mcp/auth.ts+1−0
@@ -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 own179// 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.
181const verifyJwt = (token: Redacted.Redacted<string>) =>181const 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,
apps/local/src/db/v1-v2-migration.ts+1−0
@@ -676,6 +676,7 @@ const providerGet = async ( · 675 unmodified lines
676 // The migration re-persists the value under its v2 id, so it needs the676 // 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;
packages/core/api/src/account/api.ts+1−0
@@ -96,6 +96,7 @@ export const CreateApiKeyBody = Schema.Struct({ · 95 unmodified lines
96const OneTimeSecret = Schema.String.pipe(96const 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);
packages/core/api/src/connections/api.ts+1−0
@@ -103,6 +103,7 @@ const UpdateConnectionPayload = Schema.Struct({ · 102 unmodified lines
103const PastedSecret = Schema.String.pipe(103const 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);
packages/core/sdk/src/credential-scrub.ts+1−0
@@ -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);
packages/core/sdk/src/http-auth/auth-method.ts+1−0
@@ -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};
packages/core/sdk/src/oauth-client.ts+1−0
@@ -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}
packages/core/sdk/src/oauth-discovery.ts+3−2
@@ -125,6 +125,7 @@ export type DynamicClientMetadata = { · 124 unmodified lines
125const RedactedString = Schema.String.pipe(125const 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 }
packages/core/sdk/src/oauth-helpers.ts+1−0
@@ -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 puts50/** 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. */
52const secretToSend = (value: OAuth2SecretInput): string =>52const 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
55export type OAuth2TokenResponse = {56export type OAuth2TokenResponse = {
packages/core/sdk/src/oauth-service.ts+7−2
@@ -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)) !== null1054 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 ? stored1057 ? 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 quote1199 // 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-id1200 // 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 that1223 // "<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 the1229 // Persist the requested scope set (declared ∪ client, filtered to the
packages/core/sdk/src/provider.ts+1−0
@@ -54,4 +54,5 @@ export interface CredentialProvider { · 53 unmodified lines
54 * "<redacted>", so a value that reaches a backend still wrapped is persisted as54 * "<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. */
56export const credentialValueToWrite = (value: string | Redacted.Redacted<string>): string =>56export 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;
packages/plugins/apps/src/plugin/apps-plugin.ts+1−0
@@ -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;
packages/plugins/mcp/src/sdk/plugin.ts+3−0
@@ -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, so517 // 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 the518 // 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 ),
packages/plugins/openapi/src/sdk/backing.ts+2−0
@@ -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) {
packages/react/src/pages/api-keys.tsx+1−0
@@ -106,6 +106,7 @@ export function ApiKeysPage() { · 105 unmodified lines
106 // create dialog, and the user cannot copy a "<redacted>" rendering. Scoped to106 // create dialog, and the user cannot copy a "<redacted>" rendering. Scoped to
107 // the dialog's lifetime — `closeCreate` drops `createdKey`, so the plaintext107 // 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) => {
scripts/oxlint-plugin-executor.js+2−0
@@ -18,6 +18,7 @@ import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js · 17 unmodified lines
18import noRawDurableObjectId from "./oxlint-plugin-executor/rules/no-raw-durable-object-id.js";18import noRawDurableObjectId from "./oxlint-plugin-executor/rules/no-raw-durable-object-id.js";
19import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js";19import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js";
20import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js";20import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js";
21import noRedactedUnwrap from "./oxlint-plugin-executor/rules/no-redacted-unwrap.js";
21import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js";22import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js";
22import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js";23import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js";
23import noSchemaClass from "./oxlint-plugin-executor/rules/no-schema-class.js";24import 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,
scripts/oxlint-plugin-executor/rules/no-redacted-unwrap.js+39−0
@@ -0,0 +1,39 @@
1import {
2 getPropertyName,
3 isDeclarationFile,
4 isIdentifier,
5 isTestLike,
6 unwrapExpression,
7} from "../utils.js";
8
9const 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.
15const isRedactedUnwrap = (node) =>
16 isIdentifier(unwrapExpression(node.object), "Redacted") &&
17 getPropertyName(node.property) === "value";
18
19export 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};

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t