Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <module>` adds a custom response-sanitization hook for organization-specific policies or external sanitizers.

## [0.9.8] - 2026-06-01

Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -447,6 +458,7 @@ codegraph callees <symbol> # Find what a function/method calls (--limit,
codegraph impact <symbol> # 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`
Expand Down
42 changes: 42 additions & 0 deletions __tests__/frameworks-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
8 changes: 8 additions & 0 deletions __tests__/frameworks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
89 changes: 89 additions & 0 deletions __tests__/mcp-sanitization.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
11 changes: 11 additions & 0 deletions site/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,19 @@ codegraph callees <symbol> # Find what a function/method calls (--limit,
codegraph impact <symbol> # 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.
Expand Down
10 changes: 10 additions & 0 deletions site/src/content/docs/reference/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1128,14 +1128,24 @@ program
.option('-p, --path <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 <modulePath>', '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
// the same env-var chokepoint the watcher and MCP server already honor.
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) {
Expand Down
Loading