From 02cf9264efb922787a4ffca81fbd3f4da1498414 Mon Sep 17 00:00:00 2001 From: Jiale Lin <63439129+ljluestc@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:05:06 -0700 Subject: [PATCH 1/3] feat(mcp): add optional sanitization layer for tool output --- CHANGELOG.md | 3 + PR_383_DESCRIPTION_LOCAL.md | 68 ++++++ README.md | 12 ++ __tests__/mcp-sanitization.test.ts | 89 ++++++++ site/src/content/docs/reference/cli.md | 11 + site/src/content/docs/reference/mcp-server.md | 10 + src/bin/codegraph.ts | 12 +- src/mcp/sanitization.ts | 193 ++++++++++++++++++ src/mcp/tools.ts | 12 +- 9 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 PR_383_DESCRIPTION_LOCAL.md create mode 100644 __tests__/mcp-sanitization.test.ts create mode 100644 src/mcp/sanitization.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fcf6519..1ab566150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- `codegraph serve --mcp` now supports privacy hardening before tool output reaches your agent: `--sanitize` enables built-in redaction for common PII/secrets (emails, phones, SSNs, credit cards, and common API keys), and `--sanitize-hook ` adds a custom response-sanitization hook for organization-specific policies or external sanitizers. ## [0.9.8] - 2026-06-01 diff --git a/PR_383_DESCRIPTION_LOCAL.md b/PR_383_DESCRIPTION_LOCAL.md new file mode 100644 index 000000000..7c70d57d6 --- /dev/null +++ b/PR_383_DESCRIPTION_LOCAL.md @@ -0,0 +1,68 @@ +# PR: Add optional PII sanitization layer to MCP output (`issue #383`) + +## Summary +This PR adds an optional sanitization layer in the MCP response pipeline so CodeGraph can redact sensitive content before indexed code/context is returned to downstream LLM agents. + +It introduces: +- A built-in sanitizer (`--sanitize`) for common PII/secrets. +- A custom sanitizer middleware hook (`--sanitize-hook `) for organization-specific policies and external integrations. +- Centralized response sanitization in the MCP tool execution path so all tool payloads are covered consistently. + +## Problem +Issue #383 highlights a privacy gap: indexed code can contain sensitive values (emails, phone numbers, identifiers, secret-like tokens), and CodeGraph currently returns tool output directly to the MCP client/agent. + +In regulated/compliance-sensitive environments, users need an explicit pre-LLM sanitization control without giving up MCP functionality. + +## Solution +### 1) Add built-in sanitization middleware +- New module: `src/mcp/sanitization.ts` +- Adds built-in redaction for: + - Email addresses + - Phone numbers + - US SSNs + - Credit-card-like values (with Luhn validation to reduce false positives) + - Common API-key patterns (e.g. OpenAI-style and AWS access key IDs) + +### 2) Add custom sanitizer hook +- Supports `CODEGRAPH_SANITIZE_HOOK` (or CLI `--sanitize-hook`) to load a user-provided module. +- Hook contract: function `(text: string) => string | Promise`. +- Runs in MCP response pipeline and can layer additional policy controls. + +### 3) Wire sanitization into MCP tool output path +- Updated `ToolHandler.execute()` in `src/mcp/tools.ts`. +- Sanitization is applied once centrally to final tool results, after existing wrappers (worktree/staleness handling), so response behavior remains consistent while enforcing redaction before output leaves MCP. + +### 4) Expose CLI controls +- Updated `codegraph serve` in `src/bin/codegraph.ts`: + - `--sanitize` + - `--sanitize-hook ` +- CLI options map to environment controls: + - `CODEGRAPH_SANITIZE=1` + - `CODEGRAPH_SANITIZE_HOOK=/absolute/path/to/hook.cjs` + +## Documentation updates +- `README.md` +- `site/src/content/docs/reference/cli.md` +- `site/src/content/docs/reference/mcp-server.md` +- `CHANGELOG.md` (`[Unreleased]`) + +## Tests +Added `__tests__/mcp-sanitization.test.ts` to cover: +- Built-in sanitizer redaction behavior. +- Env-gated built-in sanitization in MCP tool responses. +- Disabled-mode passthrough. +- Custom hook execution in MCP response path. + +## Validation run +- `npm run build` +- `npx vitest run __tests__/mcp-sanitization.test.ts` +- `npx vitest run __tests__/mcp-tool-allowlist.test.ts` + +## Backward compatibility +- Default behavior is unchanged unless sanitization is explicitly enabled. +- Existing MCP workflows continue to function as-is. + +## Issue linkage +- Closes #383 + +Co-Authored-By: Oz diff --git a/README.md b/README.md index 6feb5c452..f7fe428e0 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,17 @@ curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex ``` +### MCP sanitization (optional) + +```bash +codegraph serve --mcp --sanitize +codegraph serve --mcp --sanitize-hook /absolute/path/to/sanitize-hook.cjs +``` + +- `--sanitize` enables built-in redaction for common PII/secrets in tool responses (email, phone, SSN, credit cards, common API keys). +- `--sanitize-hook` loads a custom sanitizer module in the MCP response pipeline (module must export a function that accepts text and returns sanitized text). +- Env equivalents: `CODEGRAPH_SANITIZE=1`, `CODEGRAPH_SANITIZE_HOOK=/absolute/path/to/hook.cjs`. + Already have Node? Use npm instead (works on any version): ```bash @@ -447,6 +458,7 @@ codegraph callees # Find what a function/method calls (--limit, codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) codegraph affected [files...] # Find test files affected by changes (see below) codegraph serve --mcp # Start MCP server +codegraph serve --mcp --sanitize # Enable built-in PII redaction on MCP tool output ``` ### `codegraph affected` diff --git a/__tests__/mcp-sanitization.test.ts b/__tests__/mcp-sanitization.test.ts new file mode 100644 index 000000000..a25939f66 --- /dev/null +++ b/__tests__/mcp-sanitization.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ToolHandler } from '../src/mcp/tools'; +import { sanitizeText } from '../src/mcp/sanitization'; + +const ENV_SANITIZE = 'CODEGRAPH_SANITIZE'; +const ENV_HOOK = 'CODEGRAPH_SANITIZE_HOOK'; +const ORIGINAL_SANITIZE = process.env[ENV_SANITIZE]; +const ORIGINAL_HOOK = process.env[ENV_HOOK]; + +afterEach(() => { + if (ORIGINAL_SANITIZE === undefined) delete process.env[ENV_SANITIZE]; + else process.env[ENV_SANITIZE] = ORIGINAL_SANITIZE; + if (ORIGINAL_HOOK === undefined) delete process.env[ENV_HOOK]; + else process.env[ENV_HOOK] = ORIGINAL_HOOK; +}); + +describe('MCP sanitization', () => { + it('redacts common PII/secrets with built-in sanitizer', () => { + const input = [ + 'Contact: jane.doe@example.com', + 'Phone: +1 (415) 555-2671', + 'SSN: 123-45-6789', + 'Card: 4111 1111 1111 1111', + 'OpenAI key: sk-abcdefghijklmnopqrstuvwxyzABCDE12345', + 'AWS key: AKIA1234567890ABCDEF', + ].join('\n'); + const out = sanitizeText(input); + + expect(out.replacements).toBeGreaterThanOrEqual(6); + expect(out.text).toContain('[REDACTED_EMAIL]'); + expect(out.text).toContain('[REDACTED_PHONE]'); + expect(out.text).toContain('[REDACTED_SSN]'); + expect(out.text).toContain('[REDACTED_CREDIT_CARD]'); + expect(out.text).toContain('[REDACTED_API_KEY]'); + expect(out.text).not.toContain('jane.doe@example.com'); + }); + + it('applies built-in sanitization to MCP tool responses when CODEGRAPH_SANITIZE=1', async () => { + process.env[ENV_SANITIZE] = '1'; + delete process.env[ENV_HOOK]; + const handler = new ToolHandler({ + buildContext: async () => 'User email alice@example.com', + } as any); + + const result = await handler.execute('codegraph_context', { task: 'summarize auth' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0]?.text).toContain('[REDACTED_EMAIL]'); + expect(result.content[0]?.text).not.toContain('alice@example.com'); + }); + + it('leaves tool output unchanged when built-in sanitization is disabled', async () => { + delete process.env[ENV_SANITIZE]; + delete process.env[ENV_HOOK]; + const handler = new ToolHandler({ + buildContext: async () => 'User email bob@example.com', + } as any); + + const result = await handler.execute('codegraph_context', { task: 'summarize auth' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0]?.text).toContain('bob@example.com'); + }); + + it('applies a custom sanitize hook from CODEGRAPH_SANITIZE_HOOK', async () => { + delete process.env[ENV_SANITIZE]; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sanitize-hook-')); + const hookPath = path.join(tmp, 'hook.cjs'); + fs.writeFileSync( + hookPath, + 'module.exports = (text) => text.replace(/customer_id:\\s*\\d+/g, "customer_id: [REDACTED_CUSTOMER_ID]");\n', + 'utf-8' + ); + process.env[ENV_HOOK] = hookPath; + + try { + const handler = new ToolHandler({ + buildContext: async () => 'payload customer_id: 42', + } as any); + const result = await handler.execute('codegraph_context', { task: 'inspect payload' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0]?.text).toContain('[REDACTED_CUSTOMER_ID]'); + expect(result.content[0]?.text).not.toContain('customer_id: 42'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/site/src/content/docs/reference/cli.md b/site/src/content/docs/reference/cli.md index 76f0e5a31..adec94f68 100644 --- a/site/src/content/docs/reference/cli.md +++ b/site/src/content/docs/reference/cli.md @@ -20,8 +20,19 @@ codegraph callees # Find what a function/method calls (--limit, codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) codegraph affected [files...] # Find test files affected by changes codegraph serve --mcp # Start MCP server +codegraph serve --mcp --sanitize # Enable built-in PII redaction ``` +## MCP sanitization + +```bash +codegraph serve --mcp --sanitize +codegraph serve --mcp --sanitize-hook /absolute/path/to/sanitize-hook.cjs +``` + +- `--sanitize` enables built-in redaction for common PII/secrets in MCP tool responses. +- `--sanitize-hook` loads a custom sanitizer module into the MCP response pipeline (module must export a function that accepts text and returns sanitized text). + ## Query commands `query`, `callers`, `callees`, and `impact` all accept `--json` for machine-readable output. diff --git a/site/src/content/docs/reference/mcp-server.md b/site/src/content/docs/reference/mcp-server.md index a2bb3fe23..950de6bc6 100644 --- a/site/src/content/docs/reference/mcp-server.md +++ b/site/src/content/docs/reference/mcp-server.md @@ -9,6 +9,16 @@ CodeGraph runs as a [Model Context Protocol](https://modelcontextprotocol.io/) s codegraph serve --mcp ``` +Optional privacy hardening: + +```bash +codegraph serve --mcp --sanitize +codegraph serve --mcp --sanitize-hook /absolute/path/to/sanitize-hook.cjs +``` + +- `--sanitize` enables built-in redaction for common PII/secrets in MCP tool output. +- `--sanitize-hook` loads a custom sanitizer module in the response pipeline (module exports a function that accepts text and returns sanitized text). + Agents configured by the installer launch this automatically. When a `.codegraph/` index exists, the agent uses the tools below. ## Tools diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 9e7f98887..af93827f3 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1128,7 +1128,9 @@ program .option('-p, --path ', 'Project path (optional for MCP mode, uses rootUri from client)') .option('--mcp', 'Run as MCP server (stdio transport)') .option('--no-watch', 'Disable the file watcher (no auto-sync; useful on slow filesystems like WSL2 /mnt drives)') - .action(async (options: { path?: string; mcp?: boolean; watch?: boolean }) => { + .option('--sanitize', 'Enable built-in PII sanitization for MCP tool responses') + .option('--sanitize-hook ', 'Path to a custom sanitizer hook module (exports a function)') + .action(async (options: { path?: string; mcp?: boolean; watch?: boolean; sanitize?: boolean; sanitizeHook?: string }) => { const projectPath = options.path ? resolveProjectPath(options.path) : undefined; // Commander sets watch=false when --no-watch is passed. Route it through @@ -1136,6 +1138,14 @@ program if (options.watch === false) { process.env.CODEGRAPH_NO_WATCH = '1'; } + if (options.sanitize) { + process.env.CODEGRAPH_SANITIZE = '1'; + } + if (options.sanitizeHook) { + process.env.CODEGRAPH_SANITIZE_HOOK = path.isAbsolute(options.sanitizeHook) + ? options.sanitizeHook + : path.resolve(options.sanitizeHook); + } try { if (options.mcp) { diff --git a/src/mcp/sanitization.ts b/src/mcp/sanitization.ts new file mode 100644 index 000000000..096af2061 --- /dev/null +++ b/src/mcp/sanitization.ts @@ -0,0 +1,193 @@ +import * as path from 'path'; + +export interface SanitizableToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +export interface SanitizationResult { + text: string; + replacements: number; +} + +type SanitizerHook = (text: string) => string | Promise; + +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +const REDACTED_EMAIL = '[REDACTED_EMAIL]'; +const REDACTED_PHONE = '[REDACTED_PHONE]'; +const REDACTED_SSN = '[REDACTED_SSN]'; +const REDACTED_CREDIT_CARD = '[REDACTED_CREDIT_CARD]'; +const REDACTED_API_KEY = '[REDACTED_API_KEY]'; + +const EMAIL_REGEX = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi; +const SSN_REGEX = /\b\d{3}-\d{2}-\d{4}\b/g; +const PHONE_REGEX = /\b(?:\+?\d{1,3}[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)\d{3}[-.\s]?\d{4}\b/g; +const CREDIT_CARD_REGEX = /\b(?:\d[ -]?){13,19}\b/g; +const OPENAI_KEY_REGEX = /\bsk-[A-Za-z0-9]{20,}\b/g; +const AWS_KEY_REGEX = /\bAKIA[0-9A-Z]{16}\b/g; + +let cachedHookSpec: string | null = null; +let cachedHook: SanitizerHook | null = null; +let hookLoadErrorLogged = false; +let hookRuntimeErrorLogged = false; + +function isTruthy(raw: string | undefined): boolean { + if (!raw) return false; + return TRUE_VALUES.has(raw.trim().toLowerCase()); +} + +function sanitizeHookSpec(): string | null { + const raw = process.env.CODEGRAPH_SANITIZE_HOOK?.trim(); + return raw ? raw : null; +} + +function luhnValid(digits: string): boolean { + let sum = 0; + let doubleDigit = false; + for (let i = digits.length - 1; i >= 0; i--) { + let d = Number(digits[i] ?? '0'); + if (doubleDigit) { + d *= 2; + if (d > 9) d -= 9; + } + sum += d; + doubleDigit = !doubleDigit; + } + return sum % 10 === 0; +} + +function shouldRedactPhone(match: string): boolean { + const digits = match.replace(/\D/g, ''); + return digits.length >= 10 && digits.length <= 15; +} + +function replaceWithCount( + input: string, + regex: RegExp, + replacement: string, + predicate?: (match: string) => boolean, +): SanitizationResult { + let replacements = 0; + const text = input.replace(regex, (match) => { + if (predicate && !predicate(match)) { + return match; + } + replacements++; + return replacement; + }); + return { text, replacements }; +} + +export function builtInSanitizationEnabled(): boolean { + return isTruthy(process.env.CODEGRAPH_SANITIZE); +} + +export function sanitizeText(text: string): SanitizationResult { + let output = text; + let replacements = 0; + const email = replaceWithCount(output, EMAIL_REGEX, REDACTED_EMAIL); + output = email.text; + replacements += email.replacements; + + const ssn = replaceWithCount(output, SSN_REGEX, REDACTED_SSN); + output = ssn.text; + replacements += ssn.replacements; + + const phone = replaceWithCount(output, PHONE_REGEX, REDACTED_PHONE, shouldRedactPhone); + output = phone.text; + replacements += phone.replacements; + + const card = replaceWithCount(output, CREDIT_CARD_REGEX, REDACTED_CREDIT_CARD, (match) => { + const digits = match.replace(/\D/g, ''); + return digits.length >= 13 && digits.length <= 19 && luhnValid(digits); + }); + output = card.text; + replacements += card.replacements; + + const openAiKey = replaceWithCount(output, OPENAI_KEY_REGEX, REDACTED_API_KEY); + output = openAiKey.text; + replacements += openAiKey.replacements; + + const awsKey = replaceWithCount(output, AWS_KEY_REGEX, REDACTED_API_KEY); + output = awsKey.text; + replacements += awsKey.replacements; + + return { text: output, replacements }; +} + +function loadSanitizeHook(): SanitizerHook | null { + const spec = sanitizeHookSpec(); + if (spec === cachedHookSpec) { + return cachedHook; + } + + cachedHookSpec = spec; + cachedHook = null; + hookLoadErrorLogged = false; + hookRuntimeErrorLogged = false; + + if (!spec) return null; + + try { + const resolved = path.isAbsolute(spec) ? spec : path.resolve(spec); + const loaded = require(resolved) as unknown; + const candidate = + typeof loaded === 'function' + ? loaded + : (loaded as { default?: unknown; sanitize?: unknown })?.default ?? + (loaded as { sanitize?: unknown })?.sanitize; + if (typeof candidate !== 'function') { + throw new Error('module must export a function (default export or named "sanitize")'); + } + cachedHook = candidate as SanitizerHook; + } catch (err) { + if (!hookLoadErrorLogged) { + hookLoadErrorLogged = true; + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[CodeGraph MCP] Failed to load CODEGRAPH_SANITIZE_HOOK: ${msg}\n`); + } + cachedHook = null; + } + + return cachedHook; +} + +export async function sanitizeToolResult(result: SanitizableToolResult): Promise { + const useBuiltIn = builtInSanitizationEnabled(); + const hook = loadSanitizeHook(); + if (!useBuiltIn && !hook) return result; + + let changed = false; + const content: Array<{ type: 'text'; text: string }> = []; + + for (const block of result.content) { + let text = block.text; + + if (useBuiltIn) { + const sanitized = sanitizeText(text); + if (sanitized.replacements > 0) changed = true; + text = sanitized.text; + } + + if (hook) { + try { + const hooked = await Promise.resolve(hook(text)); + if (typeof hooked === 'string') { + if (hooked !== text) changed = true; + text = hooked; + } + } catch (err) { + if (!hookRuntimeErrorLogged) { + hookRuntimeErrorLogged = true; + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[CodeGraph MCP] CODEGRAPH_SANITIZE_HOOK execution failed: ${msg}\n`); + } + } + } + + content.push(text === block.text ? block : { ...block, text }); + } + + if (!changed) return result; + return { ...result, content }; +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 38e343b15..456b32e96 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -37,6 +37,7 @@ import { isGeneratedFile } from '../extraction/generated-detection'; import { tmpdir } from 'os'; import * as pathModule from 'path'; import { join, resolve as resolvePath } from 'path'; +import { sanitizeToolResult } from './sanitization'; /** Maximum output length to prevent context bloat (characters) */ const MAX_OUTPUT_LENGTH = 15000; @@ -1115,7 +1116,7 @@ export class ToolHandler { // status embeds the pending-files list as a first-class section // (see handleStatus), so we skip the auto-banner wrapper here to // avoid duplicating the same info at the top of the response. - return await this.handleStatus(args); + result = await this.handleStatus(args); break; case 'codegraph_files': result = await this.handleFiles(args); break; case 'codegraph_trace': @@ -1123,8 +1124,13 @@ export class ToolHandler { default: return this.errorResult(`Unknown tool: ${toolName}`); } - const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined); - return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined); + const wrapped = toolName === 'codegraph_status' + ? result + : this.withStalenessNotice( + this.withWorktreeNotice(result, args.projectPath as string | undefined), + args.projectPath as string | undefined + ); + return await sanitizeToolResult(wrapped); } catch (err) { return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`); } From 8f60bccf98fd5c1a8db296cbba6793501500a0e4 Mon Sep 17 00:00:00 2001 From: Jiale Lin <63439129+ljluestc@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:05:30 -0700 Subject: [PATCH 2/3] fix(frameworks): detect Hono sub-router routes and mounted prefixes --- __tests__/frameworks-integration.test.ts | 42 +++++ __tests__/frameworks.test.ts | 8 + src/resolution/frameworks/express.ts | 199 ++++++++++++++++++++++- 3 files changed, 245 insertions(+), 4 deletions(-) diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 344a0f6c9..5fc889d89 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -105,6 +105,48 @@ describe('Flask end-to-end framework extraction', () => { }); }); +describe('Hono end-to-end framework extraction', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('applies app.route mount prefixes to sub-router routes across files', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-hono-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'hono-app', dependencies: { hono: '^4.0.0' } }, null, 2) + ); + fs.mkdirSync(path.join(tmpDir, 'routes')); + fs.writeFileSync( + path.join(tmpDir, 'routes', 'users.ts'), + 'import { Hono } from \"hono\"\\n' + + 'export const userRoutes = new Hono()\\n' + + 'userRoutes.get(\"/\", listUsers)\\n' + + 'function listUsers() { return [] }\\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + 'import { Hono } from \"hono\"\\n' + + 'import { userRoutes } from \"./routes/users\"\\n' + + 'const app = new Hono()\\n' + + 'app.get(\"/health\", health)\\n' + + 'app.route(\"/users\", userRoutes)\\n' + + 'function health() { return \"ok\" }\\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const routes = cg.getNodesByKind('route').map((r) => r.name); + expect(routes).toContain('GET /health'); + expect(routes).toContain('GET /users'); + + cg.close(); + }); +}); + describe('Flutter end-to-end — setState→build synthesis', () => { let tmpDir: string | undefined; afterEach(() => { diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index c0e874908..910e1a9e8 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -245,6 +245,14 @@ describe('expressResolver.extract', () => { const { nodes, references } = expressResolver.extract!('routes.ts', src); expect(references[0].referenceName).toBe('list'); }); + + it('extracts routes from non-app/router receivers (e.g. Hono sub-router vars)', () => { + const src = `userRoutes.get('/me', getMe);\n`; + const { nodes, references } = expressResolver.extract!('userRoutes.ts', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('GET /me'); + expect(references[0].referenceName).toBe('getMe'); + }); }); import { nestjsResolver } from '../src/resolution/frameworks/nestjs'; diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index aff2c6407..ea428f6f9 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -7,6 +7,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; +import * as path from 'path'; function extractTailIdent(expr: string): string | null { const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, ''); @@ -58,7 +59,7 @@ export const expressResolver: FrameworkResolver = { try { const pkg = JSON.parse(packageJson); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; - if (deps.express || deps.fastify || deps.koa || deps.hapi) { + if (deps.express || deps.fastify || deps.koa || deps.hapi || deps.hono) { return true; } } catch { @@ -75,7 +76,14 @@ export const expressResolver: FrameworkResolver = { file.includes('middleware') ) { const content = context.readFile(file); - if (content && (content.includes('express') || content.includes('app.get') || content.includes('router.get'))) { + if (content && ( + content.includes('express') || + content.includes('hono') || + content.includes('new Hono(') || + content.includes('app.get') || + content.includes('router.get') || + content.includes('.route(') + )) { return true; } } @@ -141,12 +149,12 @@ export const expressResolver: FrameworkResolver = { // Match the route head up to the first arg: (app|router).METHOD('/path', // (NOT the whole call — handlers are often inline arrows whose `)`/`{}` the // old single-regex couldn't span, so inline-handler routes connected to nothing.) - const head = /\b(app|router)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g; + const head = /\b([A-Za-z_$][\w$]*)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g; let match: RegExpExecArray | null; while ((match = head.exec(safe)) !== null) { const method = match[2]!; const routePath = match[3]!; - if (method === 'use' && !routePath.startsWith('/')) continue; + if (routePath !== '*' && !routePath.startsWith('/')) continue; const line = safe.slice(0, match.index).split('\n').length; const routeNode: Node = { id: `route:${filePath}:${line}:${method.toUpperCase()}:${routePath}`, @@ -219,6 +227,141 @@ export const expressResolver: FrameworkResolver = { } return { nodes, references }; }, + + /** + * Apply mounted prefixes from `parent.route('/prefix', childRouter)` across + * files (Hono-style composition). The per-file extract phase captures each + * router's own `get/post/...` routes, but a child route's final URL depends + * on where that router is mounted — information that can live in another file. + * We recompute route names from stable `qualifiedName` tails so this is + * idempotent across repeated sync/index runs. + */ + postExtract(context: ResolutionContext): Node[] { + const jsFiles = context.getAllFiles().filter((f) => /\.(m?js|tsx?|cjs)$/.test(f)); + if (jsFiles.length === 0) return []; + + const ownerByRouteId = new Map(); // route.id -> `${file}::${receiver}` + const ownersByFile = new Map>(); // file -> receiver names + const mountCandidates: Array<{ + filePath: string; + parent: string; + childRef: string; + prefix: string; + language: 'typescript' | 'javascript'; + }> = []; + + const addOwner = (filePath: string, owner: string): void => { + const set = ownersByFile.get(filePath) ?? new Set(); + set.add(owner); + ownersByFile.set(filePath, set); + }; + + for (const filePath of jsFiles) { + const content = context.readFile(filePath); + if (!content) continue; + const language = detectLanguage(filePath); + const safe = stripCommentsForRegex(content, language); + + const routeRe = /\b([A-Za-z_$][\w$]*)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g; + let rm: RegExpExecArray | null; + while ((rm = routeRe.exec(safe)) !== null) { + const receiver = rm[1]!; + const method = rm[2]!.toUpperCase(); + const routePath = rm[3]!; + if (routePath !== '*' && !routePath.startsWith('/')) continue; + const line = safe.slice(0, rm.index).split('\n').length; + ownerByRouteId.set(`route:${filePath}:${line}:${method}:${routePath}`, `${filePath}::${receiver}`); + addOwner(filePath, receiver); + } + + const mountRe = /\b([A-Za-z_$][\w$]*)\.route\s*\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z_$][\w$]*)\s*\)/g; + let mm: RegExpExecArray | null; + while ((mm = mountRe.exec(safe)) !== null) { + const parent = mm[1]!; + const prefix = mm[2]!; + const childRef = mm[3]!; + if (!prefix.startsWith('/')) continue; + addOwner(filePath, parent); + mountCandidates.push({ filePath, parent, childRef, prefix, language }); + } + } + + if (mountCandidates.length === 0) return []; + + const resolveChildOwnerKey = ( + filePath: string, + childRef: string, + language: 'typescript' | 'javascript' + ): string | null => { + const localOwners = ownersByFile.get(filePath); + if (localOwners?.has(childRef)) return `${filePath}::${childRef}`; + + const imports = context.getImportMappings(filePath, language); + const mapping = imports.find((m) => m.localName === childRef); + if (!mapping) return null; + const targetFile = resolveImportedFile(filePath, mapping.source, context); + if (!targetFile) return null; + const targetOwners = ownersByFile.get(targetFile); + if (!targetOwners || targetOwners.size === 0) return null; + + if (mapping.exportedName && mapping.exportedName !== 'default' && targetOwners.has(mapping.exportedName)) { + return `${targetFile}::${mapping.exportedName}`; + } + if (targetOwners.has(childRef)) return `${targetFile}::${childRef}`; + if (targetOwners.size === 1) return `${targetFile}::${Array.from(targetOwners)[0]!}`; + return null; + }; + + const edgesByParent = new Map>(); + const incomingCount = new Map(); + const allRouterKeys = new Set(); + for (const [filePath, owners] of ownersByFile) { + for (const owner of owners) allRouterKeys.add(`${filePath}::${owner}`); + } + + for (const mount of mountCandidates) { + const parentKey = `${mount.filePath}::${mount.parent}`; + const childKey = resolveChildOwnerKey(mount.filePath, mount.childRef, mount.language); + if (!childKey) continue; + allRouterKeys.add(parentKey); + allRouterKeys.add(childKey); + const arr = edgesByParent.get(parentKey) ?? []; + arr.push({ child: childKey, prefix: mount.prefix }); + edgesByParent.set(parentKey, arr); + incomingCount.set(childKey, (incomingCount.get(childKey) ?? 0) + 1); + } + + const roots = Array.from(allRouterKeys).filter((k) => !incomingCount.has(k)); + const queue: Array<{ key: string; prefix: string }> = (roots.length > 0 ? roots : Array.from(allRouterKeys)) + .map((key) => ({ key, prefix: '' })); + const prefixesByRouter = new Map>(); + while (queue.length > 0) { + const item = queue.shift()!; + const seen = prefixesByRouter.get(item.key) ?? new Set(); + if (seen.has(item.prefix)) continue; + seen.add(item.prefix); + prefixesByRouter.set(item.key, seen); + for (const edge of edgesByParent.get(item.key) ?? []) { + queue.push({ key: edge.child, prefix: joinRoutePath(item.prefix, edge.prefix) }); + } + } + + const updates: Node[] = []; + for (const route of context.getNodesByKind('route')) { + const ownerKey = ownerByRouteId.get(route.id); + if (!ownerKey) continue; + const prefixSet = prefixesByRouter.get(ownerKey); + if (!prefixSet || prefixSet.size === 0) continue; + const nonEmpty = Array.from(prefixSet).filter((p) => p && p !== '/'); + if (nonEmpty.length !== 1) continue; + const parsed = parseRouteFromQualifiedName(route); + if (!parsed) continue; + const newName = `${parsed.method} ${joinRoutePath(nonEmpty[0]!, parsed.originalPath)}`; + if (newName === route.name) continue; + updates.push({ ...route, name: newName, updatedAt: Date.now() }); + } + return updates; + }, }; /** @@ -334,3 +477,51 @@ function detectLanguage(filePath: string): 'typescript' | 'javascript' { } return 'javascript'; } + +function parseRouteFromQualifiedName(route: Node): { method: string; originalPath: string } | null { + const sep = '::'; + const idx = route.qualifiedName.indexOf(sep); + if (idx < 0) return null; + const tail = route.qualifiedName.slice(idx + sep.length); + const colon = tail.indexOf(':'); + if (colon < 0) return null; + return { method: tail.slice(0, colon), originalPath: tail.slice(colon + 1) }; +} + +function joinRoutePath(prefix: string, subPath: string): string { + if (subPath === '*') return '*'; + const parts = [prefix, subPath] + .map((p) => p.trim()) + .map((p) => p.replace(/^\/+|\/+$/g, '')) + .filter((p) => p.length > 0); + return '/' + parts.join('/'); +} + +function resolveImportedFile( + fromFile: string, + importSource: string, + context: ResolutionContext +): string | null { + if (!importSource.startsWith('.')) return null; + const fromDir = path.posix.dirname(fromFile); + const base = path.posix.normalize(path.posix.join(fromDir, importSource)); + const candidates = [ + base, + `${base}.ts`, + `${base}.tsx`, + `${base}.js`, + `${base}.jsx`, + `${base}.mjs`, + `${base}.cjs`, + `${base}/index.ts`, + `${base}/index.tsx`, + `${base}/index.js`, + `${base}/index.jsx`, + `${base}/index.mjs`, + `${base}/index.cjs`, + ]; + for (const candidate of candidates) { + if (context.fileExists(candidate)) return candidate; + } + return null; +} From 91bd045a1fbd46f9c74d6d60030597e3423b371a Mon Sep 17 00:00:00 2001 From: Jiale Lin Date: Tue, 28 Jul 2026 00:02:36 -0700 Subject: [PATCH 3/3] chore: remove accidentally pushed PR description markdown --- PR_383_DESCRIPTION_LOCAL.md | 68 ------------------------------------- 1 file changed, 68 deletions(-) delete mode 100644 PR_383_DESCRIPTION_LOCAL.md diff --git a/PR_383_DESCRIPTION_LOCAL.md b/PR_383_DESCRIPTION_LOCAL.md deleted file mode 100644 index 7c70d57d6..000000000 --- a/PR_383_DESCRIPTION_LOCAL.md +++ /dev/null @@ -1,68 +0,0 @@ -# PR: Add optional PII sanitization layer to MCP output (`issue #383`) - -## Summary -This PR adds an optional sanitization layer in the MCP response pipeline so CodeGraph can redact sensitive content before indexed code/context is returned to downstream LLM agents. - -It introduces: -- A built-in sanitizer (`--sanitize`) for common PII/secrets. -- A custom sanitizer middleware hook (`--sanitize-hook `) for organization-specific policies and external integrations. -- Centralized response sanitization in the MCP tool execution path so all tool payloads are covered consistently. - -## Problem -Issue #383 highlights a privacy gap: indexed code can contain sensitive values (emails, phone numbers, identifiers, secret-like tokens), and CodeGraph currently returns tool output directly to the MCP client/agent. - -In regulated/compliance-sensitive environments, users need an explicit pre-LLM sanitization control without giving up MCP functionality. - -## Solution -### 1) Add built-in sanitization middleware -- New module: `src/mcp/sanitization.ts` -- Adds built-in redaction for: - - Email addresses - - Phone numbers - - US SSNs - - Credit-card-like values (with Luhn validation to reduce false positives) - - Common API-key patterns (e.g. OpenAI-style and AWS access key IDs) - -### 2) Add custom sanitizer hook -- Supports `CODEGRAPH_SANITIZE_HOOK` (or CLI `--sanitize-hook`) to load a user-provided module. -- Hook contract: function `(text: string) => string | Promise`. -- Runs in MCP response pipeline and can layer additional policy controls. - -### 3) Wire sanitization into MCP tool output path -- Updated `ToolHandler.execute()` in `src/mcp/tools.ts`. -- Sanitization is applied once centrally to final tool results, after existing wrappers (worktree/staleness handling), so response behavior remains consistent while enforcing redaction before output leaves MCP. - -### 4) Expose CLI controls -- Updated `codegraph serve` in `src/bin/codegraph.ts`: - - `--sanitize` - - `--sanitize-hook ` -- CLI options map to environment controls: - - `CODEGRAPH_SANITIZE=1` - - `CODEGRAPH_SANITIZE_HOOK=/absolute/path/to/hook.cjs` - -## Documentation updates -- `README.md` -- `site/src/content/docs/reference/cli.md` -- `site/src/content/docs/reference/mcp-server.md` -- `CHANGELOG.md` (`[Unreleased]`) - -## Tests -Added `__tests__/mcp-sanitization.test.ts` to cover: -- Built-in sanitizer redaction behavior. -- Env-gated built-in sanitization in MCP tool responses. -- Disabled-mode passthrough. -- Custom hook execution in MCP response path. - -## Validation run -- `npm run build` -- `npx vitest run __tests__/mcp-sanitization.test.ts` -- `npx vitest run __tests__/mcp-tool-allowlist.test.ts` - -## Backward compatibility -- Default behavior is unchanged unless sanitization is explicitly enabled. -- Existing MCP workflows continue to function as-is. - -## Issue linkage -- Closes #383 - -Co-Authored-By: Oz