Skip to content

fix(typescript): fail closed and add null-safety validation to McpSecurityScanner - #3440

Closed
Henry Su (hsusul) wants to merge 1 commit into
microsoft:mainfrom
hsusul:fix/mcp-tool-governance-eval
Closed

Henry Su (hsusul) wants to merge 1 commit into
microsoft:mainfrom
hsusul:fix/mcp-tool-governance-eval

Conversation

@hsusul

Copy link
Copy Markdown

Problem and Affected Boundary

In @microsoft/agent-governance-sdk (agent-governance-typescript/), McpSecurityScanner screens Model Context Protocol (MCP) tool definitions for security threats (tool poisoning, typosquatting, hidden instructions, rug pulls). When processing untrusted tool definitions from external MCP tool servers, definitions missing expected string properties (e.g. description or name omitted or null) or malformed structures caused McpSecurityScanner.scan() to crash with unhandled TypeError exceptions (such as Cannot read properties of undefined (reading 'includes') or Cannot read properties of undefined (reading 'toLowerCase')).

Furthermore, unlike the Python MCPSecurityScanner (which catches unexpected scan exceptions and fails closed with a CRITICAL threat finding), the TypeScript implementation lacked a fail-closed error boundary. Additionally, detectTyposquatting did not break after finding a match, causing a tool name matching multiple known tools within edit distance 2 to emit duplicate threat findings and artificially inflate the risk score.

Reproduction

  1. Pass an untrusted tool definition missing a description or name to McpSecurityScanner.scan():
const scanner = new McpSecurityScanner();
scanner.scan({ name: 'my_tool' } as any); // Throws TypeError: Cannot read properties of undefined (reading 'includes')

Expected vs Actual Behavior

  • Expected: McpSecurityScanner.scan() safely handles missing/null property fields without throwing runtime exceptions. If an unexpected error occurs during scanning, it fails closed returning safe = false, risk_score = 100, and a critical threat finding.
  • Actual: McpSecurityScanner.scan() threw an unhandled TypeError, crashing host process / agent gateway routines.

Root Cause

  1. McpToolDefinition required non-optional name and description strings, but internal private detection methods (detectToolPoisoning, detectTyposquatting, detectHiddenInstructions, detectRugPull) directly accessed tool.description and tool.name without null-coalescing or fallback checks.
  2. scan() executed detection steps without a try-catch error boundary.
  3. detectTyposquatting did not exit the loop after recording a typosquatting match.

Implementation

  • Updated McpToolDefinition interface to make name and description optional (name?: string; description?: string;).
  • Wrapped McpSecurityScanner.scan() in a try-catch fail-closed block that catches unexpected errors and returns a fail-closed result (safe: false, risk_score: 100, critical ToolPoisoning threat).
  • Added string sanitization (tool?.name ?? '', tool?.description ?? '') in all private detection methods.
  • Added a break after the first typosquatting match in detectTyposquatting.

Regression Coverage

Added 4 unit tests in agent-governance-typescript/tests/mcp.test.ts:

  • handles tool definition with missing description without throwing
  • handles tool definition with missing name without throwing
  • fails closed when scan method encounters an unexpected exception
  • emits at most one typosquatting threat per tool

Exact Validation Commands and Results

cd agent-governance-typescript
npm test        # PASS: 37 test suites, 564 tests passed
npm run build   # PASS: tsc clean
npm run lint    # PASS: eslint clean
git diff --check # PASS: clean

Compatibility Considerations

Backward compatible. Non-breaking bug fix for McpSecurityScanner in TypeScript SDK.

Security Disclosure Assessment

Ordinary correctness and robustness defense-in-depth bug fix in scanner exception handling and null validation. Does not expose or exploit a remote vulnerability.

Confirmation

No unrelated files, formatting changes, or lockfile churn are included.

…urityScanner

Signed-off-by: Henry Su <henrysu4707@gmail.com>
Copilot AI review requested due to automatic review settings July 26, 2026 01:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@github-actions github-actions Bot added tests size/M Medium PR (< 200 lines) labels Jul 26, 2026
@github-actions

Copy link
Copy Markdown
🤖 AI Agent: breaking-change-detector — API Compatibility

AI-generated review output. Treat it as untrusted analysis and verify before acting.

API Compatibility

Severity Change Impact
High McpToolDefinition interface now allows name and description to be optional (name?: string; description?: string;). This is a breaking change for any existing code that relies on name and description being mandatory. Consumers expecting these properties to always exist may encounter runtime errors or unexpected behavior.

@github-actions

Copy link
Copy Markdown
🤖 AI Agent: contributor-guide — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Welcome, and thank you for your thoughtful contribution!

You did a great job clearly documenting the problem and providing comprehensive regression tests.

Before merging, please ensure:

  1. The CONTRIBUTING.md guidelines are followed for commit message formatting and PR title conventions.

Refer to CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown
🤖 AI Agent: test-generator — `agent-governance-typescript/src/mcp.ts`

AI-generated review output. Treat it as untrusted analysis and verify before acting.

agent-governance-typescript/src/mcp.ts

  • scan_handles_empty_tool_definition -- Test behavior when scan() is called with an entirely empty McpToolDefinition object.
  • scan_handles_null_tool_definition -- Test behavior when scan() is called with a null or undefined tool definition.
  • detectTyposquatting_handles_empty_known_tool_names -- Test detectTyposquatting when KNOWN_TOOL_NAMES is empty.
  • detectToolPoisoning_handles_empty_poisoning_patterns -- Test detectToolPoisoning when POISONING_PATTERNS is empty.

agent-governance-typescript/tests/mcp.test.ts

  • scan_handles_large_tool_definitions -- Test scan() with a tool definition containing large strings or numerous parameters to ensure no performance or memory issues.

@github-actions

Copy link
Copy Markdown
🤖 AI Agent: code-reviewer — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

TL;DR: 0 blockers, 1 warning. Robust fix with minor follow-up needed.

# Sev Issue Where
1 Warn scan() fail-closed logic may over-report ToolPoisoning for unrelated errors agent-governance-typescript/src/mcp.ts

Action items: None (no blockers).

Warnings:

# Description Follow-up
1 Refine fail-closed error reporting to differentiate unexpected errors from genuine ToolPoisoning threats. Fine as follow-up PR.

@github-actions

Copy link
Copy Markdown
🤖 AI Agent: docs-sync-checker — Docs Sync

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Docs Sync

  • McpSecurityScanner.scan() in agent-governance-typescript/src/mcp.ts -- missing docstring
  • README.md -- section describing McpSecurityScanner needs update to reflect null-safety validation and fail-closed behavior
  • CHANGELOG.md -- missing entry for the behavioral changes in McpSecurityScanner

@github-actions

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

@github-actions

Copy link
Copy Markdown

🔴 Contributor Check: HIGH

Check Result
Profile HIGH
Credential LOW
Overall HIGH

Automated check by AGT Contributor Check.

@github-actions github-actions Bot added the needs-review:HIGH Contributor reputation check flagged HIGH risk label Jul 26, 2026

Copilot AI left a comment

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.

Pull request overview

Hardens the TypeScript SDK’s McpSecurityScanner against malformed/untrusted MCP tool definitions by adding fail-closed behavior and null-safety so scans don’t crash host processes.

TL;DR: 2 blockers, 0 warnings. Fix #1 and #2 and this ships.

# Sev Issue Where
1 Block tool_name isn’t guaranteed to be a string for untrusted inputs agent-governance-typescript/src/mcp.ts scan()
2 Block Current “sanitization” doesn’t handle non-string name/description, so detectors can still throw (and force fail-closed) agent-governance-typescript/src/mcp.ts detector methods

#1: Coerce/validate toolDefinition.name to a trimmed string before returning it as tool_name.
#2: Use typeof === 'string' guards (or explicit String(...)) before calling string methods/regex ops so malformed types don’t raise avoidable exceptions.

Changes:

  • Made McpToolDefinition.name/.description optional and added a fail-closed try/catch boundary in scan().
  • Added additional tests for missing fields, fail-closed behavior, and typosquatting deduplication.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
agent-governance-typescript/src/mcp.ts Fail-closed scan boundary; null-safety updates; stop after first typosquat match
agent-governance-typescript/tests/mcp.test.ts Regression tests for null-safety, fail-closed path, and typosquat dedupe
Comments suppressed due to low confidence (3)

agent-governance-typescript/src/mcp.ts:250

  • tool?.description ?? '' doesn’t protect against non-string description values; if description is an object/number, text.includes(...) will still throw. Normalize description to a string before scanning.
  private detectHiddenInstructions(tool: McpToolDefinition, threats: McpThreat[]): void {
    const text = tool?.description ?? '';

agent-governance-typescript/src/mcp.ts:280

  • tool?.description ?? '' can still yield a non-string if description is present but not a string (untrusted input), causing text.length / regex tests to throw. Normalize to a string using a typeof check.
  private detectRugPull(tool: McpToolDefinition, threats: McpThreat[]): void {
    const text = tool?.description ?? '';
    if (text.length <= RUG_PULL_DESCRIPTION_LENGTH) return;

agent-governance-typescript/src/mcp.ts:232

  • (tool?.name ?? '').toLowerCase() still throws if name is present but not a string (e.g., name: 123 from an untrusted server). Also, the threat message interpolates tool.name directly. Validate/coerce name once and use the sanitized value for both the distance calculation and the message.
  private detectTyposquatting(tool: McpToolDefinition, threats: McpThreat[]): void {
    const name = (tool?.name ?? '').toLowerCase();
    if (!name) return;

Comment on lines 150 to +152
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';
try {
Comment on lines 194 to 196
private detectToolPoisoning(tool: McpToolDefinition, threats: McpThreat[]): void {
const text = tool.description;
const text = tool?.description ?? '';
for (const pattern of POISONING_PATTERNS) {
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.

Comment on lines +34 to +35
name?: string;
description?: string;

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.

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.

Comment on lines +237 to +253
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');
});

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.

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.

Comment on lines +34 to +35
name?: string;
description?: string;

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.

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.

Comment on lines +237 to +253
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');
});

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.

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.

Comment on lines +34 to +35
name?: string;
description?: string;

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.

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.

Comment on lines +237 to +253
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');
});

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.

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.

@MohammadHaroonAbuomar

Copy link
Copy Markdown
Collaborator

Several requests remain unaddressed for over a week, so I'm closing the PR. Feel free to reopen once they're addressed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:HIGH Contributor reputation check flagged HIGH risk size/M Medium PR (< 200 lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants