#!/usr/bin/env bun /** * Lint: credentials live with their owner — the workspace for shared * connectors, the identity for personal ones. * * Workspace-shared credentials live on the workspace at * `{workDir}/workspaces//credentials/...`, reached only through * `WorkspaceContext` or `FileCredentialStore`. * A hand-built `join(..., "users", X, "credentials", ...)` is a regression: a * shared-connector credential would land off the workspace. * * An OAuth connection's records are keys in the credential store at the owner's * scope (`src/tools/mcp-oauth-records.ts`), so nothing names a * `users//credentials/...` path by hand. * * A BROKERED provider's home — `users//credentials///` * — needs no carve-out and must not get one. `brokeredConnectorDir` * (`src/connectors/runtime/brokered.ts`) is its single construction site and builds it from * a *variable* provider segment onto a variable root, so this lint never sees it; * anything that spells such a path literally is bypassing that site and IS the * regression. * * What this script flags: * - `join(...)` with the adjacency `"users", , "credentials"`. * - Template / string literals containing `users/<...>/credentials/`. * * What it allows: * - A `// lint-ok:credential-path` marker on the line immediately above the * construction, for the rare case the typed helper genuinely doesn't apply. * * Scope: `src/**\/*.ts`. Scripts and tests are out of scope. */ import { readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { Glob } from "bun"; import * as ts from "typescript"; const ROOT = join(import.meta.dirname ?? __dirname, ".."); const SRC_ROOT = join(ROOT, "src"); const ALLOW_MARKER = "lint-ok:credential-path"; /** * Matches a banned `users//credentials/…` path: the bare `credentials` dir * or any child — including a brokered provider's own home, which is reachable * only through `brokeredConnectorDir` and therefore never appears here as a * literal. */ const USER_CREDENTIAL_PATH_RE = /users\/[^/]+\/credentials(?:$|\/)/; interface Violation { file: string; line: number; column: number; snippet: string; reason: string; } // ── Construction predicates ──────────────────────────────────────── /** * Returns true iff `node` is `join(...)` whose args contain the * adjacency `"users", , "credentials"`. The `` slot * accepts any non-literal (identifier, property access, etc.) — the * lint is about the user-scoped-credential pattern, not the specific * userId expression. * * Mirrors `isWorkspaceConversationJoin` in shape so the codebase * has one convention for path-adjacency lints. * * Exported for the self-test under `test/unit/scripts/`. */ /** A `join` arg that is the string (or no-substitution template) literal `text`. */ function isLiteralSegment(node: ts.Node | undefined, text: string): boolean { return ( node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && node.text === text ); } export function isUserCredentialJoin(node: ts.CallExpression): boolean { const callee = node.expression; const calleeName = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null; if (calleeName !== "join") return false; const args = node.arguments; // Need the adjacency `"users", , "credentials"`. for (let i = 0; i < args.length - 2; i++) { if (!isLiteralSegment(args[i], "users") || !isLiteralSegment(args[i + 2], "credentials")) { continue; } return true; } return false; } /** * Returns true iff `node` is a template literal whose assembled text * contains the substring `users/<...>/credentials/`. Catches the * `` `${workDir}/users/${userId}/credentials/${connectorName}` `` shape * that `join` would otherwise express piecewise. */ export function isUserCredentialTemplate(node: ts.TemplateExpression): boolean { let assembled = node.head.text; for (const span of node.templateSpans) { // Placeholder so adjacency-matching works on the literal text // between substitutions. Same convention as // `check-conversation-paths.ts`. assembled += ""; assembled += span.literal.text; } return USER_CREDENTIAL_PATH_RE.test(assembled); } /** * Returns true for a string literal whose text contains the substring * `users/<...>/credentials/`. Catches hard-coded path fragments. */ export function isUserCredentialStringLiteral(node: ts.StringLiteral): boolean { return USER_CREDENTIAL_PATH_RE.test(node.text); } // ── Walker scaffolding (same shape as check-conversation-paths.ts) ── function hasAllowMarker(node: ts.Node, sourceFile: ts.SourceFile, src: string): boolean { const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); if (line === 0) return false; const lines = src.split("\n"); for (let i = line - 1; i >= Math.max(0, line - 5); i--) { const lineText = lines[i] ?? ""; if (lineText.includes(ALLOW_MARKER)) return true; const trimmed = lineText.trim(); if ( trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*") ) { continue; } return false; } return false; } function scanFile(absPath: string, violations: Violation[]): void { const src = readFileSync(absPath, "utf-8"); const sourceFile = ts.createSourceFile( absPath, src, ts.ScriptTarget.Latest, true, absPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ); function record(node: ts.Node, reason: string): void { if (hasAllowMarker(node, sourceFile, src)) return; const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); violations.push({ file: relative(ROOT, absPath), line: line + 1, column: character + 1, snippet: (src.split("\n")[line] ?? "").trim(), reason, }); } function visit(node: ts.Node): void { if (ts.isCallExpression(node) && isUserCredentialJoin(node)) { record(node, '`join(..., "users", X, "credentials", ...)` shape'); } else if (ts.isTemplateExpression(node) && isUserCredentialTemplate(node)) { record(node, "template literal builds `users//credentials/...` shape"); } else if (ts.isStringLiteral(node) && isUserCredentialStringLiteral(node)) { record(node, "string literal contains `users//credentials/...` substring"); } ts.forEachChild(node, visit); } visit(sourceFile); } async function main(): Promise { const violations: Violation[] = []; const glob = new Glob("**/*.ts"); let scanned = 0; for await (const rel of glob.scan({ cwd: SRC_ROOT })) { const abs = join(SRC_ROOT, rel); if (abs.includes("/node_modules/") || abs.includes("/dist/")) continue; if (abs.endsWith(".d.ts")) continue; scanned++; scanFile(abs, violations); } if (violations.length > 0) { console.error(`✗ Found ${violations.length} user-scoped credential path(s) in src/:\n`); for (const v of violations) { console.error(` ${v.file}:${v.line}:${v.column} — ${v.reason}`); console.error(` ${v.snippet}\n`); } console.error( "Workspace-shared credentials live at `workspaces//credentials/...` — route", ); console.error("through `WorkspaceContext` (`runtime.getWorkspaceContext(wsId)`) or"); console.error("`FileCredentialStore`."); console.error("A user's OAuth records are credential-store keys at `user` scope."); console.error( `Other legitimate exceptions (rare) require a // ${ALLOW_MARKER} comment on the line above.`, ); process.exit(1); } console.log(`✓ No user-scoped credential paths in ${scanned} src/ files`); } // Gate the side effect on direct invocation. Unit tests `import` this // module to exercise the AST predicates above without triggering the // full src/ scan + process.exit. if (import.meta.main) { main().catch((err: unknown) => { console.error(err); process.exit(1); }); }