From be1f924e180efaf92efe287f5e4a9701e4928a09 Mon Sep 17 00:00:00 2001 From: kpannala_microsoft <120966830+kpannala_microsoft@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:49:59 +0100 Subject: [PATCH] Add opt-in Defender real-time protection client Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- package.json | 2 + .../src/ObservabilityManager.ts | 17 +- packages/agents-a365-tooling/README.md | 90 ++ .../src/configuration/ToolingConfiguration.ts | 91 ++ .../ToolingConfigurationOptions.ts | 25 + .../src/defender/DefenderRtpClient.ts | 1004 +++++++++++++++++ .../src/defender/contracts.ts | 146 +++ .../agents-a365-tooling/src/defender/index.ts | 5 + packages/agents-a365-tooling/src/index.ts | 1 + ...bservabilityBuilder-configProvider.test.ts | 19 +- .../DefenderRtpConfiguration.test.ts | 92 ++ tests/tooling/defender-rtp-client.test.ts | 531 +++++++++ .../integration/defender-rtp-agent-demo.mjs | 190 ++++ .../integration/defender-rtp-live-smoke.mjs | 154 +++ 14 files changed, 2362 insertions(+), 5 deletions(-) create mode 100644 packages/agents-a365-tooling/src/defender/DefenderRtpClient.ts create mode 100644 packages/agents-a365-tooling/src/defender/contracts.ts create mode 100644 packages/agents-a365-tooling/src/defender/index.ts create mode 100644 tests/tooling/configuration/DefenderRtpConfiguration.test.ts create mode 100644 tests/tooling/defender-rtp-client.test.ts create mode 100644 tests/tooling/integration/defender-rtp-agent-demo.mjs create mode 100644 tests/tooling/integration/defender-rtp-live-smoke.mjs diff --git a/package.json b/package.json index 781f77d5..8a190cb1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "test:watch": "npm run test:watch --workspaces --if-present", "test:integration": "node --experimental-vm-modules ./node_modules/.bin/jest --config jest.integration.config.cjs", "test:integration:watch": "node --experimental-vm-modules ./node_modules/.bin/jest --config jest.integration.config.cjs --watch", + "smoke:defender-rtp": "npm run build --workspace=@microsoft/agents-a365-runtime && npm run build --workspace=@microsoft/agents-a365-tooling && node tests/tooling/integration/defender-rtp-live-smoke.mjs", + "demo:defender-rtp": "npm run build --workspace=@microsoft/agents-a365-runtime && npm run build --workspace=@microsoft/agents-a365-tooling && node tests/tooling/integration/defender-rtp-agent-demo.mjs", "lint": "npm run lint --workspaces --if-present", "lint:fix": "npm run lint:fix --workspaces --if-present", "ci": "npm run ci --workspaces --if-present", diff --git a/packages/agents-a365-observability/src/ObservabilityManager.ts b/packages/agents-a365-observability/src/ObservabilityManager.ts index 7b96c44d..5678be74 100644 --- a/packages/agents-a365-observability/src/ObservabilityManager.ts +++ b/packages/agents-a365-observability/src/ObservabilityManager.ts @@ -1,6 +1,5 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. import { ObservabilityBuilder, BuilderOptions } from './ObservabilityBuilder'; @@ -35,10 +34,18 @@ export class ObservabilityManager { public static start(options?: BuilderOptions): ObservabilityBuilder { const builder = new ObservabilityBuilder(); + if (options?.exporterOptions) { + builder.withExporterOptions(options.exporterOptions); + } + if (options?.serviceName) { builder.withService(options.serviceName, options.serviceVersion); } + if (options?.serviceNamespace) { + builder.withServiceNamespace(options.serviceNamespace); + } + if (options?.tokenResolver) { builder.withTokenResolver(options.tokenResolver); } @@ -51,6 +58,10 @@ export class ObservabilityManager { builder.withConfigurationProvider(options.configProvider); } + if (options?.customLogger) { + builder.withCustomLogger(options.customLogger); + } + builder.start(); ObservabilityManager.instance = builder; diff --git a/packages/agents-a365-tooling/README.md b/packages/agents-a365-tooling/README.md index 3f73ce7f..8a330a8c 100644 --- a/packages/agents-a365-tooling/README.md +++ b/packages/agents-a365-tooling/README.md @@ -15,6 +15,96 @@ npm install @microsoft/agents-a365-tooling For detailed usage examples and implementation guidance, see the [Microsoft Agent 365 Tooling Documentation](https://learn.microsoft.com/microsoft-agent-365/developer/tooling?tabs=nodejs). +### Defender real-time protection + +Defender protection is disabled by default. Configure the endpoint explicitly before enabling it, +then use the four lifecycle methods +`enforceAgentRequest`, `enforceAgentResponse`, `enforceToolRequest`, and +`enforceToolResponse`; `executeTool` composes both tool checks around a callback. + +```typescript +import { + DefenderRtpClient, + ToolingConfiguration, +} from '@microsoft/agents-a365-tooling'; + +const configuration = new ToolingConfiguration({ + isDefenderRtpEnabled: () => true, + defenderRtpEndpoint: () => '', +}); +const defender = new DefenderRtpClient({ + configProvider: { getConfiguration: () => configuration }, +}); + +const result = await defender.executeTool( + { + agentId, + tenantId, + blueprintId, + sessionId, + tool: { + name: 'send_email', + description: 'Sends an email on behalf of the user.', + }, + arguments: { to, subject }, + }, + { accessToken }, + () => sendEmail(to, subject), +); +``` + +Set `A365_DEFENDER_RTP_ENDPOINT` to `` when using environment-based +configuration. The SDK supports customer credentials, a host-provided token callback, an existing +access token, and Blueprint to Agent Identity FMI authentication. Host token callbacks and FMI +contexts require an explicit `tokenScope`; customer credentials default to +`api:///.default`. + +`blockAction: true` is enforced. Transport, authentication, and protocol failures return +`evaluated: false` and follow `defenderRtpFailClosed` (default is fail open). + +#### Live verification + +Unit tests mock the network. Live scripts require endpoint and authentication settings supplied +through the process environment. + +Inject the short-lived token and request identity values through the process environment, then run: + +```bash +npm run smoke:defender-rtp +``` + +Set `A365_DEFENDER_RTP_ENDPOINT` to ``. For a pre-acquired token, set +`A365_DEFENDER_RTP_ACCESS_TOKEN`, +`A365_DEFENDER_RTP_AGENT_ID`, and `A365_DEFENDER_RTP_TENANT_ID`. To exercise the built-in FMI +flow instead, set `AGENT365_TENANT_ID`, `AGENT365_AGENT_ID`, `AGENT365_CLIENT_ID` (the blueprint), +`AGENT365_CLIENT_SECRET`, and `A365_DEFENDER_RTP_TOKEN_SCOPE`. For customer credentials, +set `A365_DEFENDER_RTP_CLIENT_ID`, `A365_DEFENDER_RTP_CLIENT_SECRET`, and optionally +`A365_DEFENDER_RTP_TOKEN_SCOPE` (defaults to `api:///.default`). The runner does not print +credentials or payload content; it reports only evaluation metadata. +Set `A365_DEFENDER_RTP_BLOCKED_TEST_URL` to a test URL approved for your environment. Set +`A365_DEFENDER_RTP_REQUIRE_EXPECTED_DECISIONS=true` to require the benign payload to be allowed +and that test URL to be blocked. + +#### Video-friendly local agent demo + +Set `A365_DEFENDER_RTP_ENDPOINT` to `` and provide the customer app's tenant, +client ID, and short-lived secret, then run: + +```bash +npm run demo:defender-rtp +``` + +The deterministic local agent demonstrates: + +1. A benign turn passing `before_agent`, `before_tool`, `after_tool`, and `after_agent`. +2. A known malicious URL blocked at `before_tool`. +3. `TOOL EXECUTED: false`, proving the side effect never ran. + +Required variables: `A365_DEFENDER_RTP_TENANT_ID`, `A365_DEFENDER_RTP_CLIENT_ID`, and +`A365_DEFENDER_RTP_CLIENT_SECRET`. Set `A365_DEFENDER_RTP_BLOCKED_TEST_URL` to a test URL approved +for your environment. `A365_DEFENDER_RTP_TOKEN_SCOPE` defaults to +`api:///.default`. + ## Support For issues, questions, or feedback: diff --git a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts index 586f3e3e..1f4df6bf 100644 --- a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts +++ b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts @@ -8,6 +8,8 @@ import { MCPServerConfig } from '../contracts'; // Constants for tooling-specific settings const MCP_PLATFORM_PROD_BASE_URL = 'https://agent365.svc.cloud.microsoft'; const PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE = 'ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'; +const DEFAULT_DEFENDER_RTP_TIMEOUT_MILLISECONDS = 10000; +const DEFAULT_DEFENDER_RTP_MAX_CONTENT_CHARACTERS = 20000; /** * Resolve the OAuth scope to request for a given MCP server. @@ -107,6 +109,95 @@ export class ToolingConfiguration extends RuntimeConfiguration { return PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE; } + /** + * Whether SDK tool execution wrappers call Defender RTP before executing. + */ + get isDefenderRtpEnabled(): boolean { + const override = this.toolingOverrides.isDefenderRtpEnabled?.(); + if (override !== undefined) return override; + + return RuntimeConfiguration.parseEnvBoolean(process.env.ENABLE_A365_DEFENDER_RTP); + } + + /** + * Defender RTP endpoint. Required when Defender RTP is enabled. + */ + get defenderRtpEndpoint(): string { + const override = this.toolingOverrides.defenderRtpEndpoint?.(); + if (override?.trim()) return normalizeUrl(override); + + const envValue = process.env.A365_DEFENDER_RTP_ENDPOINT?.trim(); + if (envValue) return normalizeUrl(envValue); + + if (this.isDefenderRtpEnabled) { + throw new Error( + 'defenderRtpEndpoint is required when Defender RTP is enabled. ' + + 'Set A365_DEFENDER_RTP_ENDPOINT or provide a configuration override.', + ); + } + return ''; + } + + /** + * Optional OAuth resource scope override. + * + * The 3P webhook authorizes customer-specific audiences. Client-credential authentication + * derives api:///.default when this is not set. + */ + get defenderRtpAuthenticationScope(): string { + const override = this.toolingOverrides.defenderRtpAuthenticationScope?.()?.trim(); + if (override) return override; + + const envValue = process.env.A365_DEFENDER_RTP_AUTHENTICATION_SCOPE?.trim(); + if (envValue) return envValue; + + return ''; + } + + /** + * Maximum duration of one synchronous Defender RTP evaluation request. + */ + get defenderRtpTimeoutMilliseconds(): number { + const override = this.toolingOverrides.defenderRtpTimeoutMilliseconds?.(); + const timeout = override + ?? RuntimeConfiguration.parseEnvInt( + process.env.A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS, + DEFAULT_DEFENDER_RTP_TIMEOUT_MILLISECONDS, + ); + + if (!Number.isInteger(timeout) || timeout <= 0) { + throw new Error('defenderRtpTimeoutMilliseconds must be a positive integer.'); + } + return timeout; + } + + /** + * Whether an unavailable Defender verdict blocks the inspected action. + */ + get defenderRtpFailClosed(): boolean { + const override = this.toolingOverrides.defenderRtpFailClosed?.(); + if (override !== undefined) return override; + + return process.env.A365_DEFENDER_RTP_FAIL_MODE?.trim().toLowerCase() === 'closed'; + } + + /** + * Maximum characters retained in each content string sent to Defender. + */ + get defenderRtpMaxContentCharacters(): number { + const override = this.toolingOverrides.defenderRtpMaxContentCharacters?.(); + const maximum = override + ?? RuntimeConfiguration.parseEnvInt( + process.env.A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS, + DEFAULT_DEFENDER_RTP_MAX_CONTENT_CHARACTERS, + ); + + if (!Number.isInteger(maximum) || maximum <= 0) { + throw new Error('defenderRtpMaxContentCharacters must be a positive integer.'); + } + return maximum; + } + /** * Returns the dev-mode bearer token for an MCP server by name. * Checks BEARER_TOKEN_ first, then falls back to BEARER_TOKEN. diff --git a/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts b/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts index be68c34c..089ab3ce 100644 --- a/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts +++ b/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts @@ -23,4 +23,29 @@ export type ToolingConfigurationOptions = RuntimeConfigurationOptions & { * Falls back to MCP_PLATFORM_AUTHENTICATION_SCOPE env var, then production default. */ mcpPlatformAuthenticationScope?: () => string; + /** + * Opts SDK tool execution wrappers into Defender real-time protection. + * Disabled by default. + */ + isDefenderRtpEnabled?: () => boolean; + /** + * Defender RTP endpoint. Required when Defender RTP is enabled. + */ + defenderRtpEndpoint?: () => string; + /** + * Override for the direct Defender RTP OAuth resource scope. + */ + defenderRtpAuthenticationScope?: () => string; + /** + * Override for the Defender RTP HTTP timeout in milliseconds. + */ + defenderRtpTimeoutMilliseconds?: () => number; + /** + * Whether unavailable Defender validation blocks the action. Defaults to false (fail open). + */ + defenderRtpFailClosed?: () => boolean; + /** + * Maximum characters retained in each content string sent to Defender. + */ + defenderRtpMaxContentCharacters?: () => number; }; diff --git a/packages/agents-a365-tooling/src/defender/DefenderRtpClient.ts b/packages/agents-a365-tooling/src/defender/DefenderRtpClient.ts new file mode 100644 index 00000000..bbb8a231 --- /dev/null +++ b/packages/agents-a365-tooling/src/defender/DefenderRtpClient.ts @@ -0,0 +1,1004 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; +import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import type { TurnContext } from '@microsoft/agents-hosting'; +import { ToolingConfiguration, defaultToolingConfigurationProvider } from '../configuration'; +import { Utility } from '../Utility'; +import { + DefenderRtpAgentContext, + DefenderRtpAgentEvaluationRequest, + DefenderRtpAiSession, + DefenderRtpAuthenticationContext, + DefenderRtpDecision, + DefenderRtpEvaluationResult, + DefenderRtpInspectionPoint, + DefenderRtpToolDefinition, + DefenderRtpToolEvaluationRequest, + DefenderRtpToolResponseEvaluationRequest, +} from './contracts'; + +const FMI_SCOPE = 'api://AzureADTokenExchange/.default'; +const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; +const TOKEN_EXPIRY_BUFFER_MILLISECONDS = 5 * 60 * 1000; +const MAX_TOKEN_CACHE_ENTRIES = 100; +const DEFAULT_PLATFORM_TYPE = 'CUSTOM_BUILT_AGENTS_USING_SDK'; + +interface ResolvedAgentContext { + sessionId: string; + requestId: string; + agentId: string; + tenantId: string; + blueprintId: string; + agentName: string; + agentObjectId: string; + platformAgentId: string; + platformType: string; + userId: string; + modelName: string; + instructions: string; +} + +interface CachedToken { + key: string; + token: string; + expiresAtMilliseconds: number; +} + +export interface DefenderRtpClientOptions { + configProvider?: IConfigurationProvider; + fetchImplementation?: typeof fetch; + idFactory?: () => string; + now?: () => number; +} + +export class DefenderRtpError extends Error { + public readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = new.target.name; + if (cause !== undefined) { + this.cause = cause; + } + } +} + +/** + * The inspected action was blocked by a Defender verdict or fail-closed policy. + */ +export class DefenderRtpBlockedError extends DefenderRtpError { + constructor(public readonly evaluation: DefenderRtpEvaluationResult) { + const subject = evaluation.inspectionPoint.replace('_', ' '); + const reason = evaluation.decision.reason + ?? (evaluation.evaluated + ? 'It was flagged as unsafe.' + : 'Security validation is unavailable and fail-closed mode is enabled.'); + const diagnostics = evaluation.decision.diagnostics + ? ` [${evaluation.decision.diagnostics}]` + : ''; + super( + `${subject} was blocked by Microsoft Defender for AI. Reason: ${reason}${diagnostics} ` + + `Correlation ID: ${evaluation.correlationId}`, + ); + } +} + +export class DefenderRtpValidationError extends DefenderRtpError {} + +/** + * Opt-in client for the draft Defender third-party prevention webhook. + * + * The protocol is provisional and mirrors agent365-skills PR #78. + */ +export class DefenderRtpClient { + private readonly configProvider: IConfigurationProvider; + private readonly fetchImplementation: typeof fetch; + private readonly idFactory: () => string; + private readonly now: () => number; + private readonly tokenCache = new Map(); + private readonly inFlightTokens = new Map>(); + + constructor(options: DefenderRtpClientOptions = {}) { + this.configProvider = options.configProvider ?? defaultToolingConfigurationProvider; + this.fetchImplementation = options.fetchImplementation ?? globalThis.fetch; + this.idFactory = options.idFactory ?? randomUUID; + this.now = options.now ?? Date.now; + } + + public async evaluateAgentRequest( + request: DefenderRtpAgentEvaluationRequest, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + if (!this.configProvider.getConfiguration().isDefenderRtpEnabled) return null; + this.validateMessages(request); + const context = this.resolveContext(request, authenticationContext?.turnContext); + const activity = { + agentRequest: { + context: { a365: {} }, + timestamp: this.timestamp(), + messages: this.buildMessages('MESSAGE_ROLE_USER', request.messages), + ...(context.requestId ? { requestId: context.requestId } : {}), + }, + }; + return await this.evaluateActivity( + context, + authenticationContext, + 'before_agent', + activity, + request.tools, + ); + } + + public async evaluateAgentResponse( + request: DefenderRtpAgentEvaluationRequest, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + if (!this.configProvider.getConfiguration().isDefenderRtpEnabled) return null; + this.validateMessages(request); + const context = this.resolveContext(request, authenticationContext?.turnContext); + const activity = { + agentResponse: { + context: { a365: {} }, + timestamp: this.timestamp(), + messages: this.buildMessages('MESSAGE_ROLE_ASSISTANT', request.messages), + ...(context.requestId ? { requestId: context.requestId } : {}), + }, + }; + return await this.evaluateActivity( + context, + authenticationContext, + 'after_agent', + activity, + request.tools, + ); + } + + public async evaluateToolRequest( + request: DefenderRtpToolEvaluationRequest, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + if (!this.configProvider.getConfiguration().isDefenderRtpEnabled) return null; + this.validateToolRequest(request); + const context = this.resolveContext(request, authenticationContext?.turnContext); + const toolCallId = request.toolCallId + || `tooluse_${this.idFactory().replace(/-/g, '').slice(0, 12)}`; + return await this.evaluateToolRequestWithContext( + request, + context, + toolCallId, + authenticationContext, + ); + } + + public async evaluateToolResponse( + request: DefenderRtpToolResponseEvaluationRequest, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + if (!this.configProvider.getConfiguration().isDefenderRtpEnabled) return null; + this.validateToolRequest(request); + const context = this.resolveContext(request, authenticationContext?.turnContext); + const toolCallId = request.toolCallId + || `tooluse_${this.idFactory().replace(/-/g, '').slice(0, 12)}`; + return await this.evaluateToolResponseWithContext( + request, + context, + toolCallId, + authenticationContext, + ); + } + + public async enforceAgentRequest( + request: DefenderRtpAgentEvaluationRequest, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + const evaluation = await this.evaluateAgentRequest(request, authenticationContext); + this.throwIfBlocked(evaluation); + return evaluation; + } + + public async enforceAgentResponse( + request: DefenderRtpAgentEvaluationRequest, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + const evaluation = await this.evaluateAgentResponse(request, authenticationContext); + this.throwIfBlocked(evaluation); + return evaluation; + } + + public async enforceToolRequest( + request: DefenderRtpToolEvaluationRequest, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + const evaluation = await this.evaluateToolRequest(request, authenticationContext); + this.throwIfBlocked(evaluation); + return evaluation; + } + + public async enforceToolResponse( + request: DefenderRtpToolResponseEvaluationRequest, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + const evaluation = await this.evaluateToolResponse(request, authenticationContext); + this.throwIfBlocked(evaluation); + return evaluation; + } + + /** + * Applies before_tool and after_tool around a tool callback. + */ + public async executeTool( + request: DefenderRtpToolEvaluationRequest, + authenticationContext: DefenderRtpAuthenticationContext, + execute: () => T | Promise, + ): Promise { + if (typeof execute !== 'function') { + throw new DefenderRtpValidationError('execute must be a function.'); + } + + const configuration = this.configProvider.getConfiguration(); + if (!configuration.isDefenderRtpEnabled) { + return await execute(); + } + + this.validateToolRequest(request); + const context = this.resolveContext(request, authenticationContext?.turnContext); + const toolCallId = request.toolCallId + || `tooluse_${this.idFactory().replace(/-/g, '').slice(0, 12)}`; + const accessToken = await this.tryGetAccessToken(authenticationContext, configuration); + + if (!accessToken) { + const noToken = this.failure( + 'before_tool', + context.sessionId, + configuration, + 'entra token unavailable', + null, + 0, + ); + this.throwIfBlocked(noToken); + return await execute(); + } + + const before = await this.evaluateToolRequestWithToken( + request, + context, + toolCallId, + accessToken, + configuration, + ); + this.throwIfBlocked(before); + + const result = await execute(); + + const after = await this.evaluateToolResponseWithToken( + { ...request, result }, + context, + toolCallId, + accessToken, + configuration, + ); + this.throwIfBlocked(after); + return result; + } + + private async evaluateToolRequestWithContext( + request: DefenderRtpToolEvaluationRequest, + context: ResolvedAgentContext, + toolCallId: string, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + const configuration = this.configProvider.getConfiguration(); + if (!configuration.isDefenderRtpEnabled) return null; + const token = await this.tryGetAccessToken(authenticationContext, configuration); + if (!token) { + return this.failure( + 'before_tool', + context.sessionId, + configuration, + 'entra token unavailable', + null, + 0, + ); + } + return await this.evaluateToolRequestWithToken( + request, + context, + toolCallId, + token, + configuration, + ); + } + + private async evaluateToolResponseWithContext( + request: DefenderRtpToolResponseEvaluationRequest, + context: ResolvedAgentContext, + toolCallId: string, + authenticationContext: DefenderRtpAuthenticationContext, + ): Promise { + const configuration = this.configProvider.getConfiguration(); + if (!configuration.isDefenderRtpEnabled) return null; + const token = await this.tryGetAccessToken(authenticationContext, configuration); + if (!token) { + return this.failure( + 'after_tool', + context.sessionId, + configuration, + 'entra token unavailable', + null, + 0, + ); + } + return await this.evaluateToolResponseWithToken( + request, + context, + toolCallId, + token, + configuration, + ); + } + + private async evaluateToolRequestWithToken( + request: DefenderRtpToolEvaluationRequest, + context: ResolvedAgentContext, + toolCallId: string, + accessToken: string, + configuration: ToolingConfiguration, + ): Promise { + const toolType = request.tool.toolType ?? 'mcp'; + const activity = { + toolRequest: { + context: { a365: {} }, + timestamp: this.timestamp(), + toolName: request.tool.name, + toolCallId, + toolType, + structuredArguments: request.arguments ?? {}, + ...(request.tool.description ? { toolDescription: request.tool.description } : {}), + ...(context.requestId ? { requestId: context.requestId } : {}), + }, + }; + const session = this.buildSession(context, activity, [request.tool]); + return await this.postSession( + session, + 'before_tool', + context.sessionId, + accessToken, + configuration, + ); + } + + private async evaluateToolResponseWithToken( + request: DefenderRtpToolResponseEvaluationRequest, + context: ResolvedAgentContext, + toolCallId: string, + accessToken: string, + configuration: ToolingConfiguration, + ): Promise { + const normalizedResult = this.normalizeJsonValue(request.result); + const toolResponse: Record = { + context: { a365: {} }, + timestamp: this.timestamp(), + toolName: request.tool.name, + toolCallId, + toolType: request.tool.toolType ?? 'mcp', + ...(context.requestId ? { requestId: context.requestId } : {}), + }; + if (this.isJsonObject(normalizedResult) || Array.isArray(normalizedResult)) { + toolResponse['structuredData'] = this.clampStructure( + normalizedResult, + configuration.defenderRtpMaxContentCharacters, + ); + } else { + toolResponse['text'] = this.truncate( + String(normalizedResult ?? ''), + configuration.defenderRtpMaxContentCharacters, + ); + } + + const session = this.buildSession( + context, + { toolResponse }, + [request.tool], + ); + return await this.postSession( + session, + 'after_tool', + context.sessionId, + accessToken, + configuration, + ); + } + + private async evaluateActivity( + context: ResolvedAgentContext, + authenticationContext: DefenderRtpAuthenticationContext, + inspectionPoint: DefenderRtpInspectionPoint, + activity: Record, + tools?: DefenderRtpToolDefinition[], + ): Promise { + const configuration = this.configProvider.getConfiguration(); + if (!configuration.isDefenderRtpEnabled) return null; + const token = await this.tryGetAccessToken(authenticationContext, configuration); + if (!token) { + return this.failure( + inspectionPoint, + context.sessionId, + configuration, + 'entra token unavailable', + null, + 0, + ); + } + const session = this.buildSession(context, activity, tools); + return await this.postSession( + session, + inspectionPoint, + context.sessionId, + token, + configuration, + ); + } + + private buildSession( + context: ResolvedAgentContext, + activity: Record, + tools?: DefenderRtpToolDefinition[], + ): DefenderRtpAiSession { + const a365: Record = { + id: context.agentId, + name: context.agentName, + tenantId: context.tenantId, + }; + if (context.blueprintId) a365['blueprintId'] = context.blueprintId; + + const identifier: Record = { + a365, + platform: { + type: context.platformType, + id: context.platformAgentId, + name: context.agentName, + }, + }; + if (context.agentObjectId) { + identifier['entra'] = { + tenantId: context.tenantId, + objectId: context.agentObjectId, + ...(context.blueprintId ? { blueprintId: context.blueprintId } : {}), + }; + } + + const identity: Record = { + tenantId: context.tenantId, + appId: context.agentId, + }; + if (context.agentObjectId) identity['entraObjectId'] = context.agentObjectId; + + const agent: Record = { id: identifier, identity }; + if (tools?.length) { + agent['tools'] = tools.map(tool => ({ + id: tool.toolId ?? tool.name, + name: tool.name, + type: tool.toolType ?? 'mcp', + ...(tool.description ? { description: tool.description } : {}), + })); + } + if (context.modelName) { + agent['llmConfiguration'] = { modelName: context.modelName }; + } + if (context.instructions) { + agent['instructions'] = this.truncate( + context.instructions, + this.configProvider.getConfiguration().defenderRtpMaxContentCharacters, + ); + } + + const callerIdentity: Record = { + tenantId: context.tenantId, + appId: context.agentId, + userAgent: `agent365-sdk-agent/${context.platformType.toLowerCase()}`, + }; + if (context.userId) callerIdentity['appName'] = context.userId; + + return { + environment: { agent }, + callerIdentity, + sessionContext: { a365: { id: context.sessionId } }, + activities: [activity], + evaluationPolicy: { + type: 'EVALUATION_POLICY_TYPE_BLOCKING', + threatScenarios: [{ type: 'THREAT_SCENARIO_TYPE_ALL' }], + }, + timestamp: this.timestamp(), + }; + } + + private async postSession( + session: DefenderRtpAiSession, + inspectionPoint: DefenderRtpInspectionPoint, + correlationId: string, + accessToken: string, + configuration: ToolingConfiguration, + ): Promise { + const started = this.now(); + let body: string; + try { + body = JSON.stringify(session); + } catch (error) { + return this.failure( + inspectionPoint, + correlationId, + configuration, + 'AISession is not JSON-serializable', + null, + this.now() - started, + error, + ); + } + + const timeoutSignal = AbortSignal.timeout(configuration.defenderRtpTimeoutMilliseconds); + let response: Response; + try { + response = await this.fetchImplementation(configuration.defenderRtpEndpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-ms-correlation-id': correlationId, + }, + body, + signal: timeoutSignal, + }); + } catch (error) { + const errorName = this.isJsonObject(error) && typeof error['name'] === 'string' + ? error['name'] + : undefined; + return this.failure( + inspectionPoint, + correlationId, + configuration, + timeoutSignal.aborted || errorName === 'TimeoutError' + ? 'request timeout' + : 'request failed', + null, + this.now() - started, + error, + ); + } + + let responseBody: string; + try { + responseBody = await response.text(); + } catch (error) { + return this.failure( + inspectionPoint, + correlationId, + configuration, + 'response body could not be read', + response.status, + this.now() - started, + error, + ); + } + + if (!response.ok) { + return this.failure( + inspectionPoint, + correlationId, + configuration, + `http ${response.status}`, + response.status, + this.now() - started, + ); + } + + let payload: unknown; + try { + payload = JSON.parse(responseBody); + } catch (error) { + return this.failure( + inspectionPoint, + correlationId, + configuration, + 'non-JSON response', + response.status, + this.now() - started, + error, + ); + } + + if (!this.isJsonObject(payload) || typeof payload['blockAction'] !== 'boolean') { + return this.failure( + inspectionPoint, + correlationId, + configuration, + 'response contained no verdict', + response.status, + this.now() - started, + ); + } + + const decision = this.parseDecision(payload); + return { + allowed: !decision.blockAction, + evaluated: true, + inspectionPoint, + correlationId, + decision, + httpStatus: response.status, + error: null, + latencyMilliseconds: this.now() - started, + }; + } + + private failure( + inspectionPoint: DefenderRtpInspectionPoint, + correlationId: string, + configuration: ToolingConfiguration, + error: string, + httpStatus: number | null, + latencyMilliseconds: number, + cause?: unknown, + ): DefenderRtpEvaluationResult { + const block = configuration.defenderRtpFailClosed; + return { + allowed: !block, + evaluated: false, + inspectionPoint, + correlationId, + decision: { + blockAction: block, + reasonCode: null, + reason: block + ? 'Security validation is unavailable and this agent is configured to fail closed.' + : null, + diagnostics: null, + }, + httpStatus, + error: cause instanceof Error ? `${error}: ${cause.name}` : error, + latencyMilliseconds, + }; + } + + private parseDecision(payload: Record): DefenderRtpDecision { + const reasonCode = payload['reasonCode']; + return { + blockAction: payload['blockAction'] as boolean, + reasonCode: typeof reasonCode === 'number' && Number.isInteger(reasonCode) + ? reasonCode + : null, + reason: typeof payload['reason'] === 'string' ? payload['reason'] : null, + diagnostics: typeof payload['diagnostics'] === 'string' + ? payload['diagnostics'] + : null, + }; + } + + private async tryGetAccessToken( + authenticationContext: DefenderRtpAuthenticationContext, + configuration: ToolingConfiguration, + ): Promise { + try { + const token = await this.getAccessToken( + authenticationContext, + configuration.defenderRtpAuthenticationScope, + configuration.defenderRtpTimeoutMilliseconds, + ); + Utility.ValidateAuthToken(token); + return token; + } catch (error) { + if (error instanceof DefenderRtpValidationError) throw error; + return ''; + } + } + + private async getAccessToken( + authenticationContext: DefenderRtpAuthenticationContext, + scope: string, + timeoutMilliseconds: number, + ): Promise { + if (!authenticationContext || typeof authenticationContext !== 'object') { + throw new DefenderRtpValidationError('authenticationContext is required.'); + } + if ('accessToken' in authenticationContext) { + return authenticationContext.accessToken; + } + if ('getAccessToken' in authenticationContext) { + const tokenScope = authenticationContext.tokenScope || scope; + this.validateRequiredString(tokenScope, 'authenticationContext.tokenScope'); + return await authenticationContext.getAccessToken(tokenScope); + } + if ('clientId' in authenticationContext) { + this.validateRequiredString( + authenticationContext.tenantId, + 'authenticationContext.tenantId', + ); + this.validateRequiredString( + authenticationContext.clientId, + 'authenticationContext.clientId', + ); + this.validateRequiredString( + authenticationContext.clientSecret, + 'authenticationContext.clientSecret', + ); + const tokenScope = authenticationContext.tokenScope + || scope + || `api://${authenticationContext.clientId}/.default`; + const key = + `client:${authenticationContext.tenantId}:${authenticationContext.clientId}:${tokenScope}`; + return await this.getOrAcquireCachedToken( + key, + () => this.postTokenRequest( + `https://login.microsoftonline.com/${encodeURIComponent(authenticationContext.tenantId)}` + + '/oauth2/v2.0/token', + new URLSearchParams({ + grant_type: 'client_credentials', + client_id: authenticationContext.clientId, + client_secret: authenticationContext.clientSecret, + scope: tokenScope, + }), + timeoutMilliseconds, + ), + ); + } + + this.validateRequiredString(authenticationContext.tenantId, 'authenticationContext.tenantId'); + this.validateRequiredString(authenticationContext.agentId, 'authenticationContext.agentId'); + this.validateRequiredString( + authenticationContext.blueprintClientId, + 'authenticationContext.blueprintClientId', + ); + this.validateRequiredString( + authenticationContext.blueprintClientSecret, + 'authenticationContext.blueprintClientSecret', + ); + + const tokenScope = authenticationContext.tokenScope ?? scope; + this.validateRequiredString(tokenScope, 'authenticationContext.tokenScope'); + const key = + `fmi:${authenticationContext.tenantId}:${authenticationContext.agentId}:${tokenScope}`; + return await this.getOrAcquireCachedToken( + key, + () => this.acquireFmiToken( + authenticationContext, + tokenScope, + timeoutMilliseconds, + ), + ); + } + + private async getOrAcquireCachedToken( + key: string, + acquire: () => Promise, + ): Promise { + const cached = this.tokenCache.get(key); + if (cached + && this.now() < cached.expiresAtMilliseconds - TOKEN_EXPIRY_BUFFER_MILLISECONDS) { + this.tokenCache.delete(key); + this.tokenCache.set(key, cached); + return cached.token; + } + if (cached) this.tokenCache.delete(key); + + const inFlight = this.inFlightTokens.get(key); + if (inFlight) { + return await inFlight; + } + + const promise = acquire(); + this.inFlightTokens.set(key, promise); + try { + const token = await promise; + while (this.tokenCache.size >= MAX_TOKEN_CACHE_ENTRIES) { + const oldestKey = this.tokenCache.keys().next().value as string | undefined; + if (oldestKey === undefined) break; + this.tokenCache.delete(oldestKey); + } + this.tokenCache.set(key, { + key, + token, + expiresAtMilliseconds: this.getTokenExpirationMilliseconds(token), + }); + return token; + } finally { + if (this.inFlightTokens.get(key) === promise) { + this.inFlightTokens.delete(key); + } + } + } + + private async acquireFmiToken( + authenticationContext: { + tenantId: string; + agentId: string; + blueprintClientId: string; + blueprintClientSecret: string; + }, + scope: string, + timeoutMilliseconds: number, + ): Promise { + const tokenEndpoint = + `https://login.microsoftonline.com/${encodeURIComponent(authenticationContext.tenantId)}` + + '/oauth2/v2.0/token'; + const assertion = await this.postTokenRequest( + tokenEndpoint, + new URLSearchParams({ + grant_type: 'client_credentials', + client_id: authenticationContext.blueprintClientId, + client_secret: authenticationContext.blueprintClientSecret, + scope: FMI_SCOPE, + fmi_path: authenticationContext.agentId, + }), + timeoutMilliseconds, + ); + return await this.postTokenRequest( + tokenEndpoint, + new URLSearchParams({ + grant_type: 'client_credentials', + client_id: authenticationContext.agentId, + client_assertion_type: CLIENT_ASSERTION_TYPE, + client_assertion: assertion, + scope, + }), + timeoutMilliseconds, + ); + } + + private async postTokenRequest( + tokenEndpoint: string, + body: URLSearchParams, + timeoutMilliseconds: number, + ): Promise { + const response = await this.fetchImplementation(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: AbortSignal.timeout(timeoutMilliseconds), + }); + if (!response.ok) { + throw new Error(`Token request failed with HTTP ${response.status}.`); + } + const payload: unknown = await response.json(); + if (!this.isJsonObject(payload) || typeof payload['access_token'] !== 'string') { + throw new Error('Token response did not include access_token.'); + } + return payload['access_token']; + } + + private getTokenExpirationMilliseconds(token: string): number { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Token is not a JWT.'); + } + const payload: unknown = JSON.parse( + Buffer.from(parts[1], 'base64url').toString('utf8'), + ); + if (!this.isJsonObject(payload) || typeof payload['exp'] !== 'number') { + throw new Error('Token does not contain exp.'); + } + return payload['exp'] * 1000; + } + + private resolveContext( + request: DefenderRtpAgentContext, + turnContext?: TurnContext, + ): ResolvedAgentContext { + if (!request || typeof request !== 'object') { + throw new DefenderRtpValidationError('request is required.'); + } + const activity = turnContext?.activity; + const agentId = request.agentId + ?? activity?.getAgenticInstanceId?.() + ?? activity?.recipient?.agenticAppId + ?? ''; + const tenantId = request.tenantId + ?? activity?.getAgenticTenantId?.() + ?? activity?.recipient?.tenantId + ?? activity?.conversation?.tenantId + ?? ''; + const sessionId = request.sessionId + ?? activity?.conversation?.id + ?? `a365-${this.idFactory()}`; + const userId = request.userId + ?? activity?.from?.aadObjectId + ?? activity?.from?.id + ?? ''; + + this.validateRequiredString(agentId, 'agentId'); + this.validateRequiredString(tenantId, 'tenantId'); + this.validateRequiredString(sessionId, 'sessionId'); + + return { + sessionId, + requestId: request.requestId ?? activity?.id ?? '', + agentId, + tenantId, + blueprintId: request.blueprintId ?? '', + agentName: request.agentName ?? activity?.recipient?.name ?? 'agent365-agent', + agentObjectId: request.agentObjectId ?? '', + platformAgentId: request.platformAgentId ?? agentId, + platformType: request.platformType ?? DEFAULT_PLATFORM_TYPE, + userId, + modelName: request.modelName ?? '', + instructions: request.instructions ?? '', + }; + } + + private validateMessages(request: DefenderRtpAgentEvaluationRequest): void { + if (!request || !Array.isArray(request.messages) + || request.messages.every(message => typeof message !== 'string' || !message)) { + throw new DefenderRtpValidationError('messages must contain at least one non-empty string.'); + } + } + + private validateToolRequest(request: DefenderRtpToolEvaluationRequest): void { + if (!request?.tool || typeof request.tool !== 'object') { + throw new DefenderRtpValidationError('tool is required.'); + } + this.validateRequiredString(request.tool.name, 'tool.name'); + if (request.arguments !== undefined && !this.isJsonObject(request.arguments)) { + throw new DefenderRtpValidationError('arguments must be a JSON object when supplied.'); + } + } + + private buildMessages(role: string, messages: string[]): Array> { + const maxCharacters = this.configProvider + .getConfiguration() + .defenderRtpMaxContentCharacters; + const content = messages + .filter(message => typeof message === 'string' && message.length > 0) + .map(message => ({ text: this.truncate(message, maxCharacters) })); + return content.length ? [{ role, content }] : []; + } + + private normalizeJsonValue(value: unknown): unknown { + if (value === undefined) return null; + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? String(value) : JSON.parse(serialized); + } catch { + return String(value); + } + } + + private clampStructure(value: unknown, maxCharacters: number): unknown { + if (typeof value === 'string') return this.truncate(value, maxCharacters); + if (Array.isArray(value)) { + return value.map(item => this.clampStructure(item, maxCharacters)); + } + if (this.isJsonObject(value)) { + return Object.fromEntries( + Object.entries(value) + .map(([key, item]) => [key, this.clampStructure(item, maxCharacters)]), + ); + } + return value; + } + + private truncate(value: string, maxCharacters: number): string { + if (maxCharacters <= 0 || value.length <= maxCharacters) return value; + const remaining = value.length - maxCharacters; + return `${value.slice(0, maxCharacters)}...[truncated ${remaining} chars]`; + } + + private timestamp(): string { + return new Date(this.now()).toISOString(); + } + + private throwIfBlocked(evaluation: DefenderRtpEvaluationResult | null): void { + if (evaluation && !evaluation.allowed) { + throw new DefenderRtpBlockedError(evaluation); + } + } + + private validateRequiredString(value: unknown, fieldName: string): void { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new DefenderRtpValidationError(`${fieldName} is required.`); + } + } + + private isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } +} diff --git a/packages/agents-a365-tooling/src/defender/contracts.ts b/packages/agents-a365-tooling/src/defender/contracts.ts new file mode 100644 index 00000000..027d33a2 --- /dev/null +++ b/packages/agents-a365-tooling/src/defender/contracts.ts @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { TurnContext } from '@microsoft/agents-hosting'; + +export type DefenderRtpInspectionPoint = + | 'before_agent' + | 'after_agent' + | 'before_tool' + | 'after_tool'; + +/** + * Tool metadata included in a Security4AI AISession. + */ +export interface DefenderRtpToolDefinition { + name: string; + description?: string; + toolId?: string; + toolType?: string; + inputSchema?: Record; +} + +/** + * Identity and correlation fields shared by all four lifecycle evaluations. + */ +export interface DefenderRtpAgentContext { + sessionId?: string; + requestId?: string; + agentId?: string; + tenantId?: string; + blueprintId?: string; + agentName?: string; + agentObjectId?: string; + platformAgentId?: string; + platformType?: string; + userId?: string; + modelName?: string; + instructions?: string; +} + +export interface DefenderRtpAgentEvaluationRequest extends DefenderRtpAgentContext { + messages: string[]; + tools?: DefenderRtpToolDefinition[]; +} + +export interface DefenderRtpToolEvaluationRequest extends DefenderRtpAgentContext { + tool: DefenderRtpToolDefinition; + arguments?: Record; + toolCallId?: string; +} + +export interface DefenderRtpToolResponseEvaluationRequest + extends DefenderRtpToolEvaluationRequest { + result: unknown; +} + +/** + * Security4AI AISession protobuf-JSON payload accepted by the draft 3P webhook. + */ +export interface DefenderRtpAiSession { + environment: { + agent: Record; + }; + callerIdentity: Record; + sessionContext: { + a365: { + id: string; + }; + }; + activities: Array>; + evaluationPolicy: { + type: 'EVALUATION_POLICY_TYPE_BLOCKING'; + threatScenarios: Array<{ + type: 'THREAT_SCENARIO_TYPE_ALL'; + }>; + }; + timestamp: string; +} + +export interface DefenderRtpDecision { + blockAction: boolean; + reasonCode: number | null; + reason: string | null; + diagnostics: string | null; +} + +/** + * Normalized outcome. evaluated=false means no Defender verdict was obtained and allowed follows + * the configured fail mode. + */ +export interface DefenderRtpEvaluationResult { + allowed: boolean; + evaluated: boolean; + inspectionPoint: DefenderRtpInspectionPoint; + correlationId: string; + decision: DefenderRtpDecision; + httpStatus: number | null; + error: string | null; + latencyMilliseconds: number; +} + +/** + * Uses an already acquired Defender access token. + */ +export interface DefenderRtpAccessTokenContext { + accessToken: string; + turnContext?: TurnContext; +} + +/** + * Lets a host integrate its own cached Agent Identity token provider. + */ +export interface DefenderRtpTokenProviderContext { + getAccessToken: (scope: string) => string | Promise; + tokenScope: string; + turnContext?: TurnContext; +} + +/** + * Direct client-credentials flow used by an allowlisted 3P customer application. + */ +export interface DefenderRtpClientCredentialContext { + tenantId: string; + clientId: string; + clientSecret: string; + tokenScope?: string; + turnContext?: TurnContext; +} + +/** + * Built-in FMI three-hop authentication context. + */ +export interface DefenderRtpFmiAuthenticationContext { + tenantId: string; + agentId: string; + blueprintClientId: string; + blueprintClientSecret: string; + tokenScope: string; + turnContext?: TurnContext; +} + +export type DefenderRtpAuthenticationContext = + | DefenderRtpAccessTokenContext + | DefenderRtpTokenProviderContext + | DefenderRtpClientCredentialContext + | DefenderRtpFmiAuthenticationContext; diff --git a/packages/agents-a365-tooling/src/defender/index.ts b/packages/agents-a365-tooling/src/defender/index.ts new file mode 100644 index 00000000..5f8d2372 --- /dev/null +++ b/packages/agents-a365-tooling/src/defender/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './contracts'; +export * from './DefenderRtpClient'; diff --git a/packages/agents-a365-tooling/src/index.ts b/packages/agents-a365-tooling/src/index.ts index 47b6bc78..0492085e 100644 --- a/packages/agents-a365-tooling/src/index.ts +++ b/packages/agents-a365-tooling/src/index.ts @@ -6,3 +6,4 @@ export * from './McpToolServerConfigurationService'; export * from './contracts'; export * from './models'; export * from './configuration'; +export * from './defender'; diff --git a/tests/observability/core/observabilityBuilder-configProvider.test.ts b/tests/observability/core/observabilityBuilder-configProvider.test.ts index cba65ee2..ae10e8d7 100644 --- a/tests/observability/core/observabilityBuilder-configProvider.test.ts +++ b/tests/observability/core/observabilityBuilder-configProvider.test.ts @@ -103,9 +103,24 @@ describe('ObservabilityBuilder configProvider', () => { expect(capturedConfigProvider()).toBeUndefined(); }); - it('ObservabilityManager.start() passes configProvider through to exporter', () => { + it('ObservabilityManager.start() applies all simplified options', () => { const provider = makeProvider(true); - ObservabilityManager.start({ serviceName: 'svc', tokenResolver: () => 't', configProvider: provider }); + const customLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), event: jest.fn() }; + const namespaceSpy = jest.spyOn(ObservabilityBuilder.prototype, 'withServiceNamespace'); + + ObservabilityManager.start({ + serviceName: 'svc', + serviceNamespace: 'demo', + tokenResolver: () => 't', + exporterOptions: { useS2SEndpoint: true, maxQueueSize: 7 }, + customLogger, + configProvider: provider, + }); + expect(capturedConfigProvider()).toBe(provider); + expect(capturedOpts().useS2SEndpoint).toBe(true); + expect(capturedOpts().maxQueueSize).toBe(7); + expect(capturedLogger).toBe(customLogger); + expect(namespaceSpy).toHaveBeenCalledWith('demo'); }); }); diff --git a/tests/tooling/configuration/DefenderRtpConfiguration.test.ts b/tests/tooling/configuration/DefenderRtpConfiguration.test.ts new file mode 100644 index 00000000..85bf9631 --- /dev/null +++ b/tests/tooling/configuration/DefenderRtpConfiguration.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { ToolingConfiguration } from '../../../packages/agents-a365-tooling/src'; + +describe('Defender RTP tooling configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.ENABLE_A365_DEFENDER_RTP; + delete process.env.A365_DEFENDER_RTP_ENDPOINT; + delete process.env.A365_DEFENDER_RTP_AUTHENTICATION_SCOPE; + delete process.env.A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS; + delete process.env.A365_DEFENDER_RTP_FAIL_MODE; + delete process.env.A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('is disabled by default', () => { + expect(new ToolingConfiguration().isDefenderRtpEnabled).toBe(false); + }); + + it('does not configure an endpoint or customer audience by default', () => { + const configuration = new ToolingConfiguration(); + + expect(configuration.defenderRtpEndpoint).toBe(''); + expect(configuration.defenderRtpAuthenticationScope).toBe(''); + }); + + it('requires an endpoint when Defender RTP is enabled', () => { + const configuration = new ToolingConfiguration({ + isDefenderRtpEnabled: () => true, + }); + + expect(() => configuration.defenderRtpEndpoint).toThrow( + 'defenderRtpEndpoint is required when Defender RTP is enabled.', + ); + }); + + it('does not derive the token scope from an endpoint override', () => { + const configuration = new ToolingConfiguration({ + defenderRtpEndpoint: () => ' https://defender.example.test/v1/analyze ', + }); + + expect(configuration.defenderRtpEndpoint) + .toBe('https://defender.example.test/v1/analyze'); + expect(configuration.defenderRtpAuthenticationScope).toBe(''); + }); + + it('defaults to fail open and supports fail-closed configuration', () => { + expect(new ToolingConfiguration().defenderRtpFailClosed).toBe(false); + + process.env.A365_DEFENDER_RTP_FAIL_MODE = 'closed'; + expect(new ToolingConfiguration().defenderRtpFailClosed).toBe(true); + + expect(new ToolingConfiguration({ + defenderRtpFailClosed: () => false, + }).defenderRtpFailClosed).toBe(false); + }); + + it('uses draft timeout/content defaults and supports overrides', () => { + const configuration = new ToolingConfiguration(); + expect(configuration.defenderRtpTimeoutMilliseconds).toBe(10000); + expect(configuration.defenderRtpMaxContentCharacters).toBe(20000); + + expect(new ToolingConfiguration({ + defenderRtpTimeoutMilliseconds: () => 500, + defenderRtpMaxContentCharacters: () => 1000, + }).defenderRtpTimeoutMilliseconds).toBe(500); + expect(new ToolingConfiguration({ + defenderRtpMaxContentCharacters: () => 1000, + }).defenderRtpMaxContentCharacters).toBe(1000); + }); + + it('rejects invalid numeric overrides', () => { + expect(() => new ToolingConfiguration({ + defenderRtpTimeoutMilliseconds: () => 0, + }).defenderRtpTimeoutMilliseconds).toThrow( + 'defenderRtpTimeoutMilliseconds must be a positive integer.', + ); + expect(() => new ToolingConfiguration({ + defenderRtpMaxContentCharacters: () => 0, + }).defenderRtpMaxContentCharacters).toThrow( + 'defenderRtpMaxContentCharacters must be a positive integer.', + ); + }); +}); diff --git a/tests/tooling/defender-rtp-client.test.ts b/tests/tooling/defender-rtp-client.test.ts new file mode 100644 index 00000000..e43b1254 --- /dev/null +++ b/tests/tooling/defender-rtp-client.test.ts @@ -0,0 +1,531 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it, jest } from '@jest/globals'; +import { + DefenderRtpBlockedError, + DefenderRtpClient, + DefenderRtpToolEvaluationRequest, + ToolingConfiguration, +} from '../../packages/agents-a365-tooling/src'; + +const TEST_ENDPOINT = 'https://defender.example.test/v1/analyze'; +const DEFENDER_SCOPE = 'api://customer-app/.default'; +const FIXED_NOW = Date.parse('2026-08-31T10:00:00.000Z'); + +const allowResponse = { + blockAction: false, + reasonCode: 200, + reason: 'Allowed.', + diagnostics: '', +}; +const blockResponse = { + blockAction: true, + reasonCode: 403, + reason: 'Known malicious content detected.', + diagnostics: 'MaliciousContentPropagation', +}; + +function createToken(extraClaims: Record = {}): string { + const payload = Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + 3600, + roles: ['AIAgentsRTP.ToolInvocation'], + ...extraClaims, + })).toString('base64url'); + return `e30.${payload}.signature`; +} + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function createClient( + fetchImplementation: jest.MockedFunction, + overrides: ConstructorParameters[0] = {}, +): DefenderRtpClient { + const configuration = new ToolingConfiguration({ + isDefenderRtpEnabled: () => true, + defenderRtpEndpoint: () => TEST_ENDPOINT, + ...overrides, + }); + let id = 0; + return new DefenderRtpClient({ + configProvider: { getConfiguration: () => configuration }, + fetchImplementation, + idFactory: () => `00000000-0000-0000-0000-${String(++id).padStart(12, '0')}`, + now: () => FIXED_NOW, + }); +} + +const toolRequest: DefenderRtpToolEvaluationRequest = { + agentId: 'agent-id', + tenantId: 'tenant-id', + blueprintId: 'blueprint-id', + agentName: 'Test Agent', + sessionId: 'session-id', + userId: 'user-id', + requestId: 'tool-call-id', + toolCallId: 'tool-call-id', + tool: { + name: 'send_email', + toolId: 'mail.send', + toolType: 'mcp', + description: 'Sends an email.', + }, + arguments: { + to: 'finance-recipient', + body: 'Quarterly report.', + }, +}; + +describe('DefenderRtpClient 3P prevention webhook', () => { + it('does not acquire a token or call Defender when disabled', async () => { + const fetchImplementation = jest.fn(); + const configuration = new ToolingConfiguration({ + isDefenderRtpEnabled: () => false, + }); + const tokenProvider = jest.fn(async () => createToken()); + const client = new DefenderRtpClient({ + configProvider: { getConfiguration: () => configuration }, + fetchImplementation, + }); + const execute = jest.fn(async () => 'executed'); + + await expect(client.executeTool( + toolRequest, + { getAccessToken: tokenProvider, tokenScope: DEFENDER_SCOPE }, + execute, + )).resolves.toBe('executed'); + expect(tokenProvider).not.toHaveBeenCalled(); + expect(fetchImplementation).not.toHaveBeenCalled(); + }); + + it('does not validate requests when disabled', async () => { + const configuration = new ToolingConfiguration({ + isDefenderRtpEnabled: () => false, + }); + const client = new DefenderRtpClient({ + configProvider: { getConfiguration: () => configuration }, + }); + + await expect(client.evaluateAgentRequest( + { messages: [] }, + { accessToken: '' }, + )).resolves.toBeNull(); + await expect(client.enforceToolRequest( + {} as DefenderRtpToolEvaluationRequest, + { accessToken: '' }, + )).resolves.toBeNull(); + }); + + it('posts Security4AI toolRequest and toolResponse sessions to the configured endpoint', async () => { + const fetchImplementation = jest.fn( + async () => response(allowResponse), + ); + const client = createClient(fetchImplementation); + const execute = jest.fn(async () => ({ sent: true })); + + await expect(client.executeTool( + toolRequest, + { accessToken: createToken() }, + execute, + )).resolves.toEqual({ sent: true }); + + expect(fetchImplementation).toHaveBeenCalledTimes(2); + const [beforeUrl, beforeInit] = fetchImplementation.mock.calls[0]; + expect(beforeUrl).toBe(TEST_ENDPOINT); + expect(beforeInit?.headers).toEqual({ + Authorization: expect.stringMatching(/^Bearer /), + 'Content-Type': 'application/json', + 'x-ms-correlation-id': 'session-id', + }); + const before = JSON.parse(beforeInit?.body as string); + expect(before).toEqual({ + environment: { + agent: { + id: { + a365: { + id: 'agent-id', + name: 'Test Agent', + tenantId: 'tenant-id', + blueprintId: 'blueprint-id', + }, + platform: { + type: 'CUSTOM_BUILT_AGENTS_USING_SDK', + id: 'agent-id', + name: 'Test Agent', + }, + }, + identity: { + tenantId: 'tenant-id', + appId: 'agent-id', + }, + tools: [{ + id: 'mail.send', + name: 'send_email', + type: 'mcp', + description: 'Sends an email.', + }], + }, + }, + callerIdentity: { + tenantId: 'tenant-id', + appId: 'agent-id', + userAgent: 'agent365-sdk-agent/custom_built_agents_using_sdk', + appName: 'user-id', + }, + sessionContext: { a365: { id: 'session-id' } }, + activities: [{ + toolRequest: { + context: { a365: {} }, + timestamp: '2026-08-31T10:00:00.000Z', + toolName: 'send_email', + toolCallId: 'tool-call-id', + toolType: 'mcp', + structuredArguments: { + to: 'finance-recipient', + body: 'Quarterly report.', + }, + toolDescription: 'Sends an email.', + requestId: 'tool-call-id', + }, + }], + evaluationPolicy: { + type: 'EVALUATION_POLICY_TYPE_BLOCKING', + threatScenarios: [{ type: 'THREAT_SCENARIO_TYPE_ALL' }], + }, + timestamp: '2026-08-31T10:00:00.000Z', + }); + + const after = JSON.parse(fetchImplementation.mock.calls[1][1]?.body as string); + expect(after.activities).toEqual([{ + toolResponse: { + context: { a365: {} }, + timestamp: '2026-08-31T10:00:00.000Z', + toolName: 'send_email', + toolCallId: 'tool-call-id', + toolType: 'mcp', + requestId: 'tool-call-id', + structuredData: { sent: true }, + }, + }]); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('builds agentRequest and agentResponse activities', async () => { + const fetchImplementation = jest.fn( + async () => response(allowResponse), + ); + const client = createClient(fetchImplementation); + const authentication = { accessToken: createToken() }; + const base = { + agentId: 'agent-id', + tenantId: 'tenant-id', + sessionId: 'session-id', + requestId: 'request-id', + agentName: 'Test Agent', + }; + + await client.evaluateAgentRequest( + { ...base, messages: ['Hello'] }, + authentication, + ); + await client.evaluateAgentResponse( + { ...base, messages: ['Hi there'] }, + authentication, + ); + + const requestSession = JSON.parse(fetchImplementation.mock.calls[0][1]?.body as string); + expect(requestSession.activities[0]).toEqual({ + agentRequest: { + context: { a365: {} }, + timestamp: '2026-08-31T10:00:00.000Z', + messages: [{ + role: 'MESSAGE_ROLE_USER', + content: [{ text: 'Hello' }], + }], + requestId: 'request-id', + }, + }); + + const responseSession = JSON.parse(fetchImplementation.mock.calls[1][1]?.body as string); + expect(responseSession.activities[0]).toEqual({ + agentResponse: { + context: { a365: {} }, + timestamp: '2026-08-31T10:00:00.000Z', + messages: [{ + role: 'MESSAGE_ROLE_ASSISTANT', + content: [{ text: 'Hi there' }], + }], + requestId: 'request-id', + }, + }); + }); + + it('uses an explicit toolCallId across separate before/after evaluations', async () => { + const fetchImplementation = jest.fn( + async () => response(allowResponse), + ); + const client = createClient(fetchImplementation); + const request = { + ...toolRequest, + requestId: 'request-id', + toolCallId: 'shared-tool-call-id', + }; + + await client.evaluateToolRequest(request, { accessToken: createToken() }); + await client.evaluateToolResponse( + { ...request, result: 'result' }, + { accessToken: createToken() }, + ); + + const before = JSON.parse(fetchImplementation.mock.calls[0][1]?.body as string); + const after = JSON.parse(fetchImplementation.mock.calls[1][1]?.body as string); + expect(before.activities[0].toolRequest.toolCallId).toBe('shared-tool-call-id'); + expect(before.activities[0].toolRequest.requestId).toBe('request-id'); + expect(after.activities[0].toolResponse.toolCallId).toBe('shared-tool-call-id'); + expect(after.activities[0].toolResponse.requestId).toBe('request-id'); + }); + + it('blocks before the tool side effect on a Defender verdict', async () => { + const fetchImplementation = jest.fn( + async () => response(blockResponse), + ); + const client = createClient(fetchImplementation); + const execute = jest.fn(async () => 'must-not-run'); + + await expect(client.executeTool( + toolRequest, + { accessToken: createToken() }, + execute, + )).rejects.toMatchObject({ + name: DefenderRtpBlockedError.name, + evaluation: { + evaluated: true, + inspectionPoint: 'before_tool', + allowed: false, + }, + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it('blocks a tool response before returning it', async () => { + const fetchImplementation = jest.fn() + .mockResolvedValueOnce(response(allowResponse)) + .mockResolvedValueOnce(response(blockResponse)); + const client = createClient(fetchImplementation); + const execute = jest.fn(async () => 'malicious response'); + + await expect(client.executeTool( + toolRequest, + { accessToken: createToken() }, + execute, + )).rejects.toMatchObject({ + name: DefenderRtpBlockedError.name, + evaluation: { + evaluated: true, + inspectionPoint: 'after_tool', + allowed: false, + }, + }); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('allows on HTTP failure in the default fail-open mode', async () => { + const fetchImplementation = jest.fn( + async () => response({ error: 'Unavailable' }, 503), + ); + const client = createClient(fetchImplementation); + const result = await client.evaluateToolRequest( + toolRequest, + { accessToken: createToken() }, + ); + + expect(result).toMatchObject({ + allowed: true, + evaluated: false, + httpStatus: 503, + error: 'http 503', + }); + }); + + it('blocks on HTTP failure when fail-closed mode is enabled', async () => { + const fetchImplementation = jest.fn( + async () => response({ error: 'Unavailable' }, 503), + ); + const client = createClient(fetchImplementation, { + defenderRtpFailClosed: () => true, + }); + + await expect(client.enforceToolRequest( + toolRequest, + { accessToken: createToken() }, + )).rejects.toMatchObject({ + name: DefenderRtpBlockedError.name, + evaluation: { + allowed: false, + evaluated: false, + error: 'http 503', + }, + }); + }); + + it('treats a 200 response without blockAction as no verdict', async () => { + const fetchImplementation = jest.fn( + async () => response({ reason: 'No verdict.' }), + ); + const client = createClient(fetchImplementation); + + await expect(client.evaluateToolRequest( + toolRequest, + { accessToken: createToken() }, + )).resolves.toMatchObject({ + allowed: true, + evaluated: false, + error: 'response contained no verdict', + }); + }); + + it('truncates messages and recursively clamps structured tool responses', async () => { + const fetchImplementation = jest.fn( + async () => response(allowResponse), + ); + const client = createClient(fetchImplementation, { + defenderRtpMaxContentCharacters: () => 4, + }); + + await client.evaluateAgentRequest( + { + agentId: 'agent-id', + tenantId: 'tenant-id', + sessionId: 'session-id', + messages: ['abcdefgh'], + }, + { accessToken: createToken() }, + ); + await client.evaluateToolResponse( + { + ...toolRequest, + result: { nested: ['abcdefgh'] }, + }, + { accessToken: createToken() }, + ); + + const agentSession = JSON.parse(fetchImplementation.mock.calls[0][1]?.body as string); + expect(agentSession.activities[0].agentRequest.messages[0].content[0].text) + .toBe('abcd...[truncated 4 chars]'); + const toolSession = JSON.parse(fetchImplementation.mock.calls[1][1]?.body as string); + expect(toolSession.activities[0].toolResponse.structuredData) + .toEqual({ nested: ['abcd...[truncated 4 chars]'] }); + }); + + it('performs the FMI three-hop token flow and caches the Defender token', async () => { + const defenderToken = createToken(); + const fetchImplementation = jest.fn() + .mockResolvedValueOnce(response({ access_token: 'fmi-assertion' })) + .mockResolvedValueOnce(response({ access_token: defenderToken })) + .mockResolvedValue(response(allowResponse)); + const client = createClient(fetchImplementation); + const authentication = { + tenantId: 'tenant-id', + agentId: 'agent-id', + blueprintClientId: 'blueprint-id', + blueprintClientSecret: 'secret', + tokenScope: DEFENDER_SCOPE, + }; + + await client.evaluateToolRequest(toolRequest, authentication); + await client.evaluateToolRequest(toolRequest, authentication); + + expect(fetchImplementation).toHaveBeenCalledTimes(4); + const firstTokenBody = fetchImplementation.mock.calls[0][1]?.body as URLSearchParams; + expect(firstTokenBody.get('scope')).toBe('api://AzureADTokenExchange/.default'); + expect(firstTokenBody.get('fmi_path')).toBe('agent-id'); + const secondTokenBody = fetchImplementation.mock.calls[1][1]?.body as URLSearchParams; + expect(secondTokenBody.get('client_id')).toBe('agent-id'); + expect(secondTokenBody.get('client_assertion')).toBe('fmi-assertion'); + expect(secondTokenBody.get('scope')).toBe(DEFENDER_SCOPE); + expect(fetchImplementation.mock.calls[2][0]).toBe(TEST_ENDPOINT); + expect(fetchImplementation.mock.calls[3][0]).toBe(TEST_ENDPOINT); + }); + + it('derives the self-audience for an allowlisted client-credentials app', async () => { + const accessToken = createToken(); + const fetchImplementation = jest.fn() + .mockResolvedValueOnce(response({ access_token: accessToken })) + .mockResolvedValue(response(allowResponse)); + const client = createClient(fetchImplementation); + + await client.evaluateToolRequest( + toolRequest, + { + tenantId: 'tenant-id', + clientId: 'customer-app', + clientSecret: 'secret', + }, + ); + await client.evaluateToolRequest( + toolRequest, + { + tenantId: 'tenant-id', + clientId: 'customer-app', + clientSecret: 'secret', + }, + ); + + const tokenBody = fetchImplementation.mock.calls[0][1]?.body as URLSearchParams; + expect(tokenBody.get('scope')).toBe('api://customer-app/.default'); + expect(fetchImplementation.mock.calls[1][0]).toBe(TEST_ENDPOINT); + expect(fetchImplementation.mock.calls[2][0]).toBe(TEST_ENDPOINT); + expect(fetchImplementation).toHaveBeenCalledTimes(3); + }); + + it('caches tokens independently for multiple customer apps', async () => { + const tokenA = createToken({ azp: 'customer-a' }); + const tokenB = createToken({ azp: 'customer-b' }); + const fetchImplementation = jest.fn( + async (url, init) => { + if (String(url).includes('login.microsoftonline.com')) { + const body = init?.body as URLSearchParams; + return response({ + access_token: body.get('client_id') === 'customer-a' ? tokenA : tokenB, + }); + } + return response(allowResponse); + }, + ); + const client = createClient(fetchImplementation); + const customerA = { + tenantId: 'tenant-id', + clientId: 'customer-a', + clientSecret: 'secret-a', + }; + const customerB = { + tenantId: 'tenant-id', + clientId: 'customer-b', + clientSecret: 'secret-b', + }; + + await client.evaluateToolRequest(toolRequest, customerA); + await client.evaluateToolRequest(toolRequest, customerB); + await client.evaluateToolRequest(toolRequest, customerA); + + const tokenCalls = fetchImplementation.mock.calls + .filter(([url]) => String(url).includes('login.microsoftonline.com')); + expect(tokenCalls).toHaveLength(2); + }); + + it('rejects a host token provider without an explicit audience', async () => { + const client = createClient(jest.fn()); + + await expect(client.evaluateToolRequest( + toolRequest, + { + getAccessToken: async () => createToken(), + } as never, + )).rejects.toThrow('authenticationContext.tokenScope is required.'); + }); +}); diff --git a/tests/tooling/integration/defender-rtp-agent-demo.mjs b/tests/tooling/integration/defender-rtp-agent-demo.mjs new file mode 100644 index 00000000..96ddbf73 --- /dev/null +++ b/tests/tooling/integration/defender-rtp-agent-demo.mjs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + DefenderRtpClient, + ToolingConfiguration, +} = require('../../../packages/agents-a365-tooling/dist/cjs/index.js'); + +const colors = { + reset: '\u001b[0m', + bold: '\u001b[1m', + cyan: '\u001b[36m', + green: '\u001b[32m', + red: '\u001b[31m', + yellow: '\u001b[33m', +}; + +function required(name) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required.`); + return value; +} + +function heading(text) { + console.log(`\n${colors.bold}${colors.cyan}=== ${text} ===${colors.reset}`); +} + +function printDecision(evaluation) { + if (!evaluation) throw new Error('Defender integration was unexpectedly disabled.'); + const color = evaluation.allowed ? colors.green : colors.red; + const verdict = evaluation.allowed ? 'ALLOW' : 'BLOCK'; + console.log( + `${color}[${verdict}]${colors.reset} ${evaluation.inspectionPoint} ` + + `(HTTP ${evaluation.httpStatus ?? 'n/a'}, reason ${evaluation.decision.reasonCode ?? 'n/a'})`, + ); + return evaluation; +} + +function assertAllowed(evaluation) { + printDecision(evaluation); + if (!evaluation?.evaluated || !evaluation.allowed) { + throw new Error(`Expected ${evaluation?.inspectionPoint ?? 'evaluation'} to be allowed.`); + } +} + +async function runAllowedTurn(client, authentication, identity) { + heading('Turn 1 - benign request completes all four security gates'); + const sessionId = randomUUID(); + const toolCallId = `tooluse_${randomUUID().replaceAll('-', '').slice(0, 12)}`; + + console.log('User: What is 2 + 2?'); + assertAllowed(await client.evaluateAgentRequest( + { + ...identity, + sessionId, + requestId: 'turn-1', + messages: ['What is 2 + 2?'], + }, + authentication, + )); + + const tool = { + name: 'add_numbers', + toolId: 'demo.add_numbers', + toolType: 'local', + description: 'Adds two numbers and returns their sum.', + }; + const toolRequest = { + ...identity, + sessionId, + requestId: 'turn-1', + toolCallId, + tool, + arguments: { a: 2, b: 2 }, + }; + + assertAllowed(await client.evaluateToolRequest(toolRequest, authentication)); + const result = 2 + 2; + console.log(`${colors.yellow}[TOOL EXECUTED]${colors.reset} add_numbers(2, 2) -> ${result}`); + assertAllowed(await client.evaluateToolResponse( + { ...toolRequest, result }, + authentication, + )); + + const answer = `2 + 2 = ${result}.`; + assertAllowed(await client.evaluateAgentResponse( + { + ...identity, + sessionId, + requestId: 'turn-1', + messages: [answer], + }, + authentication, + )); + console.log(`Agent: ${answer}`); +} + +async function runBlockedTurn(client, authentication, identity) { + heading('Turn 2 - malicious tool call is blocked before execution'); + const sessionId = randomUUID(); + let toolExecuted = false; + + console.log('User: Fetch the requested security test URL.'); + assertAllowed(await client.evaluateAgentRequest( + { + ...identity, + sessionId, + requestId: 'turn-2', + messages: ['Fetch the requested security test URL.'], + }, + authentication, + )); + + const evaluation = await client.evaluateToolRequest( + { + ...identity, + sessionId, + requestId: 'turn-2', + toolCallId: `tooluse_${randomUUID().replaceAll('-', '').slice(0, 12)}`, + tool: { + name: 'fetch_url', + toolId: 'demo.fetch_url', + toolType: 'mcp', + description: 'Fetches a URL.', + }, + arguments: { + url: required('A365_DEFENDER_RTP_BLOCKED_TEST_URL'), + }, + }, + authentication, + ); + printDecision(evaluation); + + if (evaluation?.allowed) { + toolExecuted = true; + } + + console.log( + `${toolExecuted ? colors.red : colors.green}` + + `[TOOL EXECUTED: ${toolExecuted}]${colors.reset}`, + ); + if (!evaluation?.evaluated || evaluation.allowed || toolExecuted) { + throw new Error('Expected Defender to block the malicious tool call before execution.'); + } + console.log(`${colors.bold}${colors.green}Protection verified: side effect prevented.${colors.reset}`); +} + +async function main() { + const tenantId = required('A365_DEFENDER_RTP_TENANT_ID'); + const clientId = required('A365_DEFENDER_RTP_CLIENT_ID'); + const clientSecret = required('A365_DEFENDER_RTP_CLIENT_SECRET'); + const configuration = new ToolingConfiguration({ + isDefenderRtpEnabled: () => true, + defenderRtpEndpoint: () => required('A365_DEFENDER_RTP_ENDPOINT'), + defenderRtpFailClosed: () => true, + }); + const client = new DefenderRtpClient({ + configProvider: { getConfiguration: () => configuration }, + }); + const authentication = { + tenantId, + clientId, + clientSecret, + tokenScope: process.env.A365_DEFENDER_RTP_TOKEN_SCOPE?.trim() + || `api://${clientId}/.default`, + }; + const identity = { + tenantId, + agentId: clientId, + agentName: 'Defender RTP SDK Demo Agent', + platformAgentId: clientId, + }; + + heading('Agent365 Node.js SDK - Defender RTP live demo'); + + await runAllowedTurn(client, authentication, identity); + await runBlockedTurn(client, authentication, identity); + + heading('Demo completed successfully'); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : 'Unknown demo failure.'; + console.error(`\n${colors.red}[DEMO FAILED]${colors.reset} ${message}`); + process.exitCode = 1; +}); diff --git a/tests/tooling/integration/defender-rtp-live-smoke.mjs b/tests/tooling/integration/defender-rtp-live-smoke.mjs new file mode 100644 index 00000000..74aeda27 --- /dev/null +++ b/tests/tooling/integration/defender-rtp-live-smoke.mjs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + DefenderRtpClient, + ToolingConfiguration, +} = require('../../../packages/agents-a365-tooling/dist/cjs/index.js'); + +function readEnvironmentVariable(...names) { + const value = names + .map((name) => process.env[name]?.trim()) + .find(Boolean); + if (!value) { + throw new Error(`${names.join(' or ')} is required for the live Defender RTP smoke test.`); + } + return value; +} + +function decodeTokenClaims(accessToken) { + try { + return JSON.parse( + Buffer.from(accessToken.split('.')[1], 'base64url').toString('utf8'), + ); + } catch { + throw new Error('A365_DEFENDER_RTP_ACCESS_TOKEN could not be decoded as a JWT.'); + } +} + +async function report(label, evaluation) { + if (!evaluation) throw new Error('Defender RTP was unexpectedly disabled.'); + console.log(JSON.stringify({ + label, + evaluated: evaluation.evaluated, + blockAction: evaluation.decision.blockAction, + reasonCode: evaluation.decision.reasonCode, + httpStatus: evaluation.httpStatus, + error: evaluation.error, + })); + return evaluation; +} + +async function main() { + const accessToken = process.env.A365_DEFENDER_RTP_ACCESS_TOKEN?.trim(); + const customerClientId = process.env.A365_DEFENDER_RTP_CLIENT_ID?.trim(); + const agentId = process.env.AGENT365_AGENT_ID?.trim() + || process.env.A365_DEFENDER_RTP_AGENT_ID?.trim() + || customerClientId; + if (!agentId) { + throw new Error( + 'AGENT365_AGENT_ID, A365_DEFENDER_RTP_AGENT_ID, or ' + + 'A365_DEFENDER_RTP_CLIENT_ID is required for the live Defender RTP smoke test.', + ); + } + const tenantId = readEnvironmentVariable( + 'AGENT365_TENANT_ID', + 'A365_DEFENDER_RTP_TENANT_ID', + ); + const expectedAudience = process.env.A365_DEFENDER_RTP_EXPECTED_AUDIENCE?.trim(); + if (accessToken) { + const claims = decodeTokenClaims(accessToken); + if (expectedAudience && claims.aud !== expectedAudience) { + throw new Error( + `A365_DEFENDER_RTP_ACCESS_TOKEN must have audience ${expectedAudience}.`, + ); + } + } + + const authentication = accessToken + ? { accessToken } + : customerClientId + ? { + tenantId, + clientId: customerClientId, + clientSecret: readEnvironmentVariable('A365_DEFENDER_RTP_CLIENT_SECRET'), + tokenScope: process.env.A365_DEFENDER_RTP_TOKEN_SCOPE?.trim() || undefined, + } + : { + tenantId, + agentId, + blueprintClientId: readEnvironmentVariable( + 'AGENT365_CLIENT_ID', + 'AGENT365_BLUEPRINT_ID', + ), + blueprintClientSecret: readEnvironmentVariable('AGENT365_CLIENT_SECRET'), + tokenScope: process.env.A365_DEFENDER_RTP_TOKEN_SCOPE?.trim() || undefined, + }; + const configuration = new ToolingConfiguration({ + isDefenderRtpEnabled: () => true, + defenderRtpEndpoint: () => readEnvironmentVariable('A365_DEFENDER_RTP_ENDPOINT'), + defenderRtpFailClosed: () => true, + }); + const client = new DefenderRtpClient({ + configProvider: { getConfiguration: () => configuration }, + }); + const common = { + agentId, + tenantId, + agentName: 'Defender RTP SDK Smoke Test', + }; + + const benign = await report( + 'benign-agent-request', + await client.evaluateAgentRequest( + { + ...common, + sessionId: randomUUID(), + messages: ['Help me add two numbers.'], + }, + authentication, + ), + ); + + const adversarial = await report( + 'malicious-tool-request', + await client.evaluateToolRequest( + { + ...common, + sessionId: randomUUID(), + tool: { + name: 'fetch_url', + toolId: 'smoke.fetch_url', + toolType: 'mcp', + description: 'Fetches a URL.', + }, + arguments: { + url: readEnvironmentVariable('A365_DEFENDER_RTP_BLOCKED_TEST_URL'), + }, + }, + authentication, + ), + ); + + if (!benign.evaluated || !adversarial.evaluated) { + throw new Error('The webhook did not return a verdict for every smoke payload.'); + } + if (process.env.A365_DEFENDER_RTP_REQUIRE_EXPECTED_DECISIONS === 'true') { + if (benign.decision.blockAction) { + throw new Error('The benign smoke payload was unexpectedly blocked.'); + } + if (!adversarial.decision.blockAction) { + throw new Error('The malicious URL smoke payload was unexpectedly allowed.'); + } + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : 'Unknown live smoke test failure.'; + console.error(`[Defender RTP smoke] ${message}`); + process.exitCode = 1; +});