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+
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.
.oxlintrc.jsonc+1−0
@@ -22,6 +22,7 @@ · 21 unmodified lines
2222 "executor/no-promise-reject": "error",
2323 "executor/no-raw-durable-object-id": "error",
2424 "executor/no-raw-fetch": "error",
25+ "executor/no-redacted-unwrap": "error",
2526 "executor/no-redundant-primitive-cast": "error",
2627 "executor/no-redundant-error-factory": "error",
2728 "executor/no-schema-class": "error",
apps/cloud/executor.config.ts+5−1
@@ -155,8 +155,12 @@ export default defineExecutorConfig({ · 154 unmodified lines
155155 allowPrivateGitHosts: allowLocalNetwork === true,
156156 }),
157157 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.
158162 workosVaultPlugin({
159− credentials: workosCredentials ?? { apiKey: "", clientId: "" },
163+ ...(workosCredentials ? { credentials: workosCredentials } : {}),
160164 ...(workosVaultClient ? { client: workosVaultClient } : {}),
161165 }),
162166 ] as const,
apps/cloud/src/auth/bearer.ts+1−0
@@ -45,4 +45,5 @@ export const bearerCredential = (request: Request): BearerCredential => { · 44 unmodified lines
4545 * dispatch on. Reads the plaintext to count segments and retains none of it,
4646 * so the call sites stay `Redacted`-only. */
4747 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
4849 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
2727 cookie: (httpEffect, { credential }) =>
2828 Effect.gen(function* () {
2929 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
3031 .authenticateSealedSession(Redacted.value(credential))
3132 .pipe(Effect.orElseSucceed(() => null));
3233
@@ -52,6 +53,7 @@ export const SessionAuthLive = Layer.effect( · 19 unmodified lines
5253 },
5354 };
5455
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
5557 const session = sessionFromSealed(result, Redacted.value(credential));
5658 const response = yield* httpEffect.pipe(
5759 Effect.provideService(SessionContext, session),
@@ -74,6 +76,7 @@ export const OrgAuthLive = Layer.effect( · 16 unmodified lines
7476 cookie: (httpEffect, { credential }) =>
7577 Effect.gen(function* () {
7678 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
7780 .authenticateSealedSession(Redacted.value(credential))
7881 .pipe(Effect.orElseSucceed(() => null));
7982
@@ -85,6 +88,7 @@ export const OrgAuthLive = Layer.effect( · 5 unmodified lines
8588 return yield* Effect.fail(new NoOrganization());
8689 }
8790
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
8892 const session = sessionFromSealed(result, Redacted.value(credential));
8993 const auth = {
9094 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
112112 Effect.gen(function* () {
113113 // Unwrapped for the signature check itself — jose verifies the token's own
114114 // bytes; nothing derived from them is retained.
115+ // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: jose verifies the token's own bytes
115116 const verified = yield* verifyWorkosUserManagementToken(Redacted.value(token), jwt.jwks).pipe(
116117 Effect.catchTag("McpJwtVerificationError", (error) =>
117118 Effect.fail(
apps/cloud/src/auth/workos.ts+1−0
@@ -521,6 +521,7 @@ const make = Effect.gen(function* () { · 520 unmodified lines
521521 */
522522 validateApiKey: (value: Redacted.Redacted<string>) =>
523523 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
524525 (wos) => wos.apiKeys.validateApiKey({ value: Redacted.value(value) }) as Promise<unknown>,
525526 ),
526527
apps/cloud/src/extensions/billing/route.ts+15−1
@@ -76,6 +76,20 @@ const handler = Effect.gen(function* () { · 75 unmodified lines
7676 )
7777 : undefined;
7878
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+
7993 const { statusCode, response } = yield* Effect.promise(() =>
8094 autumnHandler({
8195 request: {
@@ -89,7 +103,7 @@ const handler = Effect.gen(function* () { · 7 unmodified lines
89103 email: session.email,
90104 },
91105 clientOptions: {
92− secretKey: env.AUTUMN_SECRET_KEY ?? "",
106+ secretKey,
93107 // autumn-js's handler reads `baseURL` to override the Autumn endpoint
94108 // (not `serverURL`, which it silently ignores). Without this, a non-prod
95109 // 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
179179 // Unwrapped for the signature check itself — jose verifies the token's own
180180 // bytes; nothing derived from them is retained.
181181 const verifyJwt = (token: Redacted.Redacted<string>) =>
182+ // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: jose verifies the token's own bytes
182183 verifyWorkOSMcpAccessToken(Redacted.value(token), jwks, {
183184 issuer: AUTHKIT_DOMAIN,
184185 audience: WORKOS_CLIENT_ID,
apps/local/src/db/v1-v2-migration.ts+1−0
@@ -676,6 +676,7 @@ const providerGet = async ( · 675 unmodified lines
676676 // The migration re-persists the value under its v2 id, so it needs the
677677 // plaintext here and nowhere else.
678678 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
679680 return value === null ? null : Redacted.value(value);
680681 }
681682 return null;
packages/core/api/src/account/api.ts+1−0
@@ -96,6 +96,7 @@ export const CreateApiKeyBody = Schema.Struct({ · 95 unmodified lines
9696 const OneTimeSecret = Schema.String.pipe(
9797 Schema.decodeTo(Schema.Redacted(Schema.String), {
9898 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
99100 encode: SchemaGetter.transform(Redacted.value<string>),
100101 }),
101102 );
packages/core/api/src/connections/api.ts+1−0
@@ -103,6 +103,7 @@ const UpdateConnectionPayload = Schema.Struct({ · 102 unmodified lines
103103 const PastedSecret = Schema.String.pipe(
104104 Schema.decodeTo(Schema.Redacted(Schema.String), {
105105 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
106107 encode: SchemaGetter.transform(Redacted.value<string>),
107108 }),
108109 );
packages/core/sdk/src/credential-scrub.ts+1−0
@@ -41,6 +41,7 @@ export const makeCredentialScrubber = ( · 40 unmodified lines
4141 ): CredentialScrubber => {
4242 const secrets = Object.values(values)
4343 .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
4445 .map(Redacted.value)
4546 .filter((secret) => secret.length > 0)
4647 .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
9292 if (placement.literal !== undefined) return placement.literal;
9393 const wrapped = values[placement.variable ?? TOKEN_VARIABLE];
9494 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
9596 const value = Redacted.value(wrapped);
9697 return placement.prefix ? `${placement.prefix}${value}` : value;
9798 };
packages/core/sdk/src/oauth-client.ts+1−0
@@ -87,6 +87,7 @@ export function oauthClientSecretFromInput( · 86 unmodified lines
8787 value: string | Redacted.Redacted<string> | null | undefined,
8888 ): string | Redacted.Redacted<string> | null {
8989 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
9091 const literal = Redacted.isRedacted(value) ? Redacted.value(value) : value;
9192 return literal.length === 0 ? null : value;
9293 }
packages/core/sdk/src/oauth-discovery.ts+3−2
@@ -125,6 +125,7 @@ export type DynamicClientMetadata = { · 124 unmodified lines
125125 const RedactedString = Schema.String.pipe(
126126 Schema.decodeTo(Schema.Redacted(Schema.String), {
127127 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
128129 encode: SchemaGetter.transform(Redacted.value<string>),
129130 }),
130131 );
@@ -542,9 +543,9 @@ export const registerDynamicClient = ( · 411 unmodified lines
542543 accept: "application/json",
543544 };
544545 if (input.initialAccessToken != null) {
545− // Boundary: the RFC 7591 initial access token goes out as a bearer header.
546546 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)
548549 : input.initialAccessToken;
549550 if (token.length > 0) headers.authorization = `Bearer ${token}`;
550551 }
packages/core/sdk/src/oauth-helpers.ts+1−0
@@ -50,6 +50,7 @@ export type OAuth2SecretInput = string | Redacted.Redacted<string>; · 49 unmodified lines
5050 /** Unwrap at an allowlisted boundary: `oauth4webapi` takes bare strings and puts
5151 * them on the wire. Every call site is a form field or an HTTP header. */
5252 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
5354 Redacted.isRedacted(value) ? Redacted.value(value) : value;
5455
5556 export type OAuth2TokenResponse = {
packages/core/sdk/src/oauth-service.ts+7−2
@@ -614,7 +614,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 613 unmodified lines
614614 if (
615615 input.clientSecret != null &&
616616 (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)
618619 : input.clientSecret) === ""
619620 ) {
620621 return yield* new StorageError({
@@ -1050,7 +1051,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 429 unmodified lines
10501051 ProviderItemId.make(String(row.client_secret_item_id)),
10511052 );
10521053 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
10541057 ? stored
10551058 : null;
10561059 }
@@ -1196,6 +1199,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 139 unmodified lines
11961199 // the authorize URL, and returned to the caller so `complete` can quote
11971200 // it. It cannot stay wrapped, so the unwrap happens here at the branded-id
11981201 // 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
11991203 const state = OAuthState.make(Redacted.value(createOAuthState()));
12001204 const providerState = encodeOAuthCallbackState({
12011205 state: String(state),
@@ -1219,6 +1223,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 17 unmodified lines
12191223 // "<redacted>", so a missed unwrap here would silently store that
12201224 // literal and every later `complete` would fail PKCE validation.
12211225 // (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
12221227 pkce_verifier: Redacted.value(verifier),
12231228 identity_label: input.identityLabel ?? null,
12241229 // 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
5454 * "<redacted>", so a value that reaches a backend still wrapped is persisted as
5555 * that literal instead of failing. */
5656 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
5758 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
262262 ProviderKey.make(config.tokenProvider),
263263 ProviderItemId.make(config.tokenItemId),
264264 );
265+ // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the git source API takes a bare bearer token
265266 return token === null ? null : Redacted.value(token);
266267 }
267268 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
517517 // to the transport's Authorization header and its type is a plain string, so
518518 // the token must be unwrapped to cross into third-party code. Kept inside the
519519 // 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
520521 tokens: () => ({ access_token: Redacted.value(accessToken), token_type: "Bearer" }),
521522 saveTokens: () => undefined,
522523 redirectToAuthorization: async () => {
@@ -585,6 +586,7 @@ const buildConnectorInput = ( · 62 unmodified lines
585586 const value = values[variable];
586587 // Boundary: the child process's environment block is a wire format —
587588 // 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
588590 if (value != null) env[variable] = Redacted.value(value);
589591 }
590592 }
@@ -653,6 +655,7 @@ const credentialIdentity = ( · 62 unmodified lines
653655 Object.fromEntries(
654656 Object.entries(values).map(([variable, value]) => [
655657 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
656659 value === null ? null : Redacted.value(value),
657660 ]),
658661 ),
packages/plugins/openapi/src/sdk/backing.ts+2−0
@@ -679,6 +679,7 @@ export const invokeOpenApiBackedTool = (input: { · 678 unmodified lines
679679 if (template) {
680680 const missing = requiredTemplateVariables(template).filter((name) => {
681681 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
682683 return value == null || Redacted.value(value) === "";
683684 });
684685 if (missing.length > 0) {
@@ -933,6 +934,7 @@ export const checkHealthOpenApi = (input: { · 248 unmodified lines
933934 if (template) {
934935 const missing = requiredTemplateVariables(template).filter((name) => {
935936 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
936938 return value == null || Redacted.value(value) === "";
937939 });
938940 if (missing.length > 0) {
packages/react/src/pages/api-keys.tsx+1−0
@@ -106,6 +106,7 @@ export function ApiKeysPage() { · 105 unmodified lines
106106 // create dialog, and the user cannot copy a "<redacted>" rendering. Scoped to
107107 // the dialog's lifetime — `closeCreate` drops `createdKey`, so the plaintext
108108 // 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
109110 const createdKeyPlaintext = createdKey ? Redacted.value(createdKey.value) : null;
110111
111112 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
1818 import noRawDurableObjectId from "./oxlint-plugin-executor/rules/no-raw-durable-object-id.js";
1919 import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js";
2020 import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js";
21+import noRedactedUnwrap from "./oxlint-plugin-executor/rules/no-redacted-unwrap.js";
2122 import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js";
2223 import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js";
2324 import noSchemaClass from "./oxlint-plugin-executor/rules/no-schema-class.js";
@@ -62,6 +63,7 @@ export default { · 38 unmodified lines
6263 "no-raw-durable-object-id": noRawDurableObjectId,
6364 "no-raw-fetch": noRawFetch,
6465 "no-raw-error-throw": noRawErrorThrow,
66+ "no-redacted-unwrap": noRedactedUnwrap,
6567 "no-redundant-primitive-cast": noRedundantPrimitiveCast,
6668 "no-redundant-error-factory": noRedundantErrorFactory,
6769 "no-schema-class": noSchemaClass,
scripts/oxlint-plugin-executor/rules/no-redacted-unwrap.js+39−0
@@ -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+};

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