Ctrl K
ComunidadGuías…

scripts/check-credential-paths.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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/<wsId>/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/<id>/credentials/...` path by hand.
 *
 * A BROKERED provider's home — `users/<userId>/credentials/<provider>/<connector>/`
 * — 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", <id>, "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/<id>/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", <userId>, "credentials"`. The `<userId>` 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", <userId>, "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 += "<expr>";
    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/<id>/credentials/...` shape");
    } else if (ts.isStringLiteral(node) && isUserCredentialStringLiteral(node)) {
      record(node, "string literal contains `users/<id>/credentials/...` substring");
    }
    ts.forEachChild(node, visit);
  }

  visit(sourceFile);
}

async function main(): Promise<void> {
  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/<wsId>/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);
  });
}