Skip to content
Closed
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
67 changes: 43 additions & 24 deletions agent-governance-typescript/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export interface McpScanResult {

/** Minimal MCP tool definition accepted by the scanner. */
export interface McpToolDefinition {
name: string;
description: string;
name?: string;
description?: string;
Comment on lines +34 to +35

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making name/description optional on the exported McpToolDefinition (re-exported from src/index.ts:27) is a type-level break for consumers reading these fields under strict mode; the runtime coalescing alone handles untrusted input, so the PR body's backward-compatible claim is inaccurate. Keep the fields required or acknowledge the typing break.

Comment on lines +34 to +35

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making name/description optional on the exported McpToolDefinition (re-exported from src/index.ts:27) is a type-level break for consumers reading these fields under strict mode; the runtime coalescing alone handles untrusted input, so the PR body's backward-compatible claim is inaccurate. Keep the fields required or acknowledge the typing break.

Comment on lines +34 to +35

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making name/description optional on the exported McpToolDefinition (re-exported from src/index.ts:27) is a type-level break for consumers reading these fields under strict mode; the runtime coalescing alone handles untrusted input, so the PR body's backward-compatible claim is inaccurate. Keep the fields required or acknowledge the typing break.

parameters?: Record<string, unknown>;
}

Expand Down Expand Up @@ -148,24 +148,40 @@ const SEVERITY_WEIGHT: Record<McpThreat['severity'], number> = {
export class McpSecurityScanner {
/** Scan a single tool definition. */
scan(toolDefinition: McpToolDefinition): McpScanResult {
const threats: McpThreat[] = [];

this.detectToolPoisoning(toolDefinition, threats);
this.detectTyposquatting(toolDefinition, threats);
this.detectHiddenInstructions(toolDefinition, threats);
this.detectRugPull(toolDefinition, threats);

const risk_score = Math.min(
100,
threats.reduce((sum, t) => sum + SEVERITY_WEIGHT[t.severity], 0),
);

return {
tool_name: toolDefinition.name,
threats,
risk_score,
safe: threats.length === 0,
};
const toolName = toolDefinition?.name ?? 'unknown';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On main, scan({}) and scan(undefined) raise a TypeError. On this branch both return safe true with risk_score 0, so the catch block never runs for the malformed definitions this change targets. A tool definition with no description to scan should return unsafe.

try {
Comment on lines 150 to +152
const threats: McpThreat[] = [];

this.detectToolPoisoning(toolDefinition, threats);
this.detectTyposquatting(toolDefinition, threats);
this.detectHiddenInstructions(toolDefinition, threats);
this.detectRugPull(toolDefinition, threats);

const risk_score = Math.min(
100,
threats.reduce((sum, t) => sum + SEVERITY_WEIGHT[t.severity], 0),
);

return {
tool_name: toolName,
threats,
risk_score,
safe: threats.length === 0,
};
} catch (err) {
return {
tool_name: toolName,
threats: [
{
type: McpThreatType.ToolPoisoning,
severity: 'critical',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the catch embeds raw err.message into the returned threat description; Python logs and returns a generic message. Mirror that to avoid leaking internal error detail.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the catch embeds raw err.message into the returned threat description; Python logs and returns a generic message. Mirror that to avoid leaking internal error detail.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the catch embeds raw err.message into the returned threat description; Python logs and returns a generic message. Mirror that to avoid leaking internal error detail.

description: `Scan error — fail closed: ${err instanceof Error ? err.message : String(err)}`,
},
],
risk_score: 100,
safe: false,
};
}
}

/** Scan multiple tool definitions. */
Expand All @@ -176,7 +192,7 @@ export class McpSecurityScanner {
// ── Private detection methods ──

private detectToolPoisoning(tool: McpToolDefinition, threats: McpThreat[]): void {
const text = tool.description;
const text = tool?.description ?? '';
for (const pattern of POISONING_PATTERNS) {
Comment on lines 194 to 196
const match = pattern.exec(text);
if (match) {
Expand Down Expand Up @@ -211,7 +227,9 @@ export class McpSecurityScanner {
}

private detectTyposquatting(tool: McpToolDefinition, threats: McpThreat[]): void {
const name = tool.name.toLowerCase();
const name = (tool?.name ?? '').toLowerCase();
if (!name) return;

for (const known of KNOWN_TOOL_NAMES) {
if (name === known) continue; // exact match is fine
const dist = levenshtein(name, known);
Expand All @@ -222,12 +240,13 @@ export class McpSecurityScanner {
description: `Tool name "${tool.name}" is suspiciously similar to known tool "${known}" (edit distance ${dist})`,
evidence: known,
});
break; // stop after first typosquatting match to prevent duplicate threat inflation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the new break in detectTyposquatting stops at the first match in KNOWN_TOOL_NAMES list order, not the closest. Concrete under-report: creat_file matches read_file at distance 2 (medium, listed first) and never reaches create_file at distance 1 (high), dropping risk 75 -> 25. Python emits one finding per similar name with no dedup (mcp_security.py:956-980). Select the minimum-distance match if dedup is wanted, or drop the break.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the new break in detectTyposquatting stops at the first match in KNOWN_TOOL_NAMES list order, not the closest. Concrete under-report: creat_file matches read_file at distance 2 (medium, listed first) and never reaches create_file at distance 1 (high), dropping risk 75 -> 25. Python emits one finding per similar name with no dedup (mcp_security.py:956-980). Select the minimum-distance match if dedup is wanted, or drop the break.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the new break in detectTyposquatting stops at the first match in KNOWN_TOOL_NAMES list order, not the closest. Concrete under-report: creat_file matches read_file at distance 2 (medium, listed first) and never reaches create_file at distance 1 (high), dropping risk 75 -> 25. Python emits one finding per similar name with no dedup (mcp_security.py:956-980). Select the minimum-distance match if dedup is wanted, or drop the break.

}
}
}

private detectHiddenInstructions(tool: McpToolDefinition, threats: McpThreat[]): void {
const text = tool.description;
const text = tool?.description ?? '';

// Zero-width characters
for (const zwc of ZERO_WIDTH_CHARS) {
Expand Down Expand Up @@ -257,7 +276,7 @@ export class McpSecurityScanner {
}

private detectRugPull(tool: McpToolDefinition, threats: McpThreat[]): void {
const text = tool.description;
const text = tool?.description ?? '';
if (text.length <= RUG_PULL_DESCRIPTION_LENGTH) return;

let instructionMatches = 0;
Expand Down
50 changes: 50 additions & 0 deletions agent-governance-typescript/tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,54 @@ describe('McpSecurityScanner', () => {
expect(result.risk_score).toBeGreaterThan(0);
});
});

// ── Edge cases and Fail-Closed ──

describe('null safety and fail-closed handling', () => {
it('handles tool definition with missing description without throwing', () => {
const tool: McpToolDefinition = {
name: 'read_file',
};
const result = scanner.scan(tool);
expect(result.safe).toBe(true);
expect(result.tool_name).toBe('read_file');
});

it('handles tool definition with missing name without throwing', () => {
const tool: McpToolDefinition = {
description: 'Reads a file.',
};
const result = scanner.scan(tool);
expect(result.safe).toBe(true);
expect(result.tool_name).toBe('unknown');
});

it('fails closed when scan method encounters an unexpected exception', () => {
const tool: McpToolDefinition = {
name: 'test_tool',
description: 'Test description',
};
// Force an error in internal detector
jest.spyOn(scanner as any, 'detectToolPoisoning').mockImplementation(() => {
throw new Error('Unexpected memory error');
});

const result = scanner.scan(tool);
expect(result.safe).toBe(false);
expect(result.risk_score).toBe(100);
expect(result.threats).toHaveLength(1);
expect(result.threats[0].severity).toBe('critical');
expect(result.threats[0].description).toContain('Scan error — fail closed');
});
Comment on lines +237 to +253

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the fail-closed test only forces the error via a jest mock of a private method; add one organic malformed-input case (e.g. description: 123 as any) so the catch is exercised end-to-end.

Comment on lines +237 to +253

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the fail-closed test only forces the error via a jest mock of a private method; add one organic malformed-input case (e.g. description: 123 as any) so the catch is exercised end-to-end.

Comment on lines +237 to +253

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the fail-closed test only forces the error via a jest mock of a private method; add one organic malformed-input case (e.g. description: 123 as any) so the catch is exercised end-to-end.


it('emits at most one typosquatting threat per tool', () => {
const tool: McpToolDefinition = {
name: 'serch', // dist 1 from search, dist 2 from fetch
description: 'Web search tool',
};
const result = scanner.scan(tool);
const typosquats = result.threats.filter((t) => t.type === McpThreatType.Typosquatting);
expect(typosquats).toHaveLength(1);
});
});
});
Loading