Skip to content

Commit a3ef0a5

Browse files
committed
feat(a2a): implement secure acknowledgement and correct agent indexing
1 parent c61ed08 commit a3ef0a5

File tree

4 files changed

+179
-17
lines changed

4 files changed

+179
-17
lines changed

packages/core/src/agents/acknowledgedAgents.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ export class AcknowledgedAgentsService {
6666
hash: string,
6767
): Promise<boolean> {
6868
await this.load();
69+
return this.isAcknowledgedSync(projectPath, agentName, hash);
70+
}
71+
72+
/**
73+
* Synchronous check for acknowledgment.
74+
* Note: Assumes load() has already been called and awaited (e.g. during registry init).
75+
*/
76+
isAcknowledgedSync(
77+
projectPath: string,
78+
agentName: string,
79+
hash: string,
80+
): boolean {
6981
const projectAgents = this.acknowledgedAgents[projectPath];
7082
if (!projectAgents) return false;
7183
return projectAgents[agentName] === hash;

packages/core/src/agents/registry.test.ts

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8+
import * as crypto from 'node:crypto';
89
import { AgentRegistry, getModelConfigAlias } from './registry.js';
910
import { makeFakeConfig } from '../test-utils/config.js';
1011
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
@@ -29,10 +30,23 @@ import { SimpleExtensionLoader } from '../utils/extensionLoader.js';
2930
import type { ToolRegistry } from '../tools/tool-registry.js';
3031
import { ThinkingLevel } from '@google/genai';
3132
import type { AcknowledgedAgentsService } from './acknowledgedAgents.js';
33+
import * as sdkClient from '@a2a-js/sdk/client';
34+
import { safeFetch } from '../utils/fetch.js';
3235
import { PolicyDecision } from '../policy/types.js';
3336
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
3437
import type { A2AAuthProvider } from './auth-provider/types.js';
3538

39+
vi.mock('@a2a-js/sdk/client', async (importOriginal) => {
40+
const actual = await importOriginal();
41+
return {
42+
...(actual as Record<string, unknown>),
43+
DefaultAgentCardResolver: vi.fn().mockImplementation((options) => ({
44+
fetchImpl: options?.fetchImpl,
45+
resolve: vi.fn().mockResolvedValue({ name: 'RemoteAgent' }),
46+
})),
47+
};
48+
});
49+
3650
vi.mock('./agentLoader.js', () => ({
3751
loadAgentsFromDirectory: vi
3852
.fn()
@@ -417,7 +431,7 @@ describe('AgentRegistry', () => {
417431
expect(registry.getDefinition('extension-agent')).toBeUndefined();
418432
});
419433

420-
it('should use agentCardUrl as hash for acknowledgement of remote agents', async () => {
434+
it('should use agentCardUrl and content-based hash for acknowledgement of remote agents', async () => {
421435
mockConfig = makeMockedConfig({ enableAgents: true });
422436
// Trust the folder so it attempts to load project agents
423437
vi.spyOn(mockConfig, 'isTrustedFolder').mockReturnValue(true);
@@ -453,21 +467,75 @@ describe('AgentRegistry', () => {
453467
clearCache: vi.fn(),
454468
} as unknown as A2AClientManager);
455469

470+
// Mock the resolver to return a consistent card content for hashing
471+
const mockCardContent = { name: 'RemoteAgent' };
472+
const expectedContentHash = crypto
473+
.createHash('sha256')
474+
.update(JSON.stringify(mockCardContent))
475+
.digest('hex');
476+
const expectedHash = `https://example.com/card#${expectedContentHash}`;
477+
478+
vi.mocked(sdkClient.DefaultAgentCardResolver).mockImplementation(
479+
() =>
480+
({
481+
resolve: vi.fn().mockResolvedValue(mockCardContent),
482+
}) as unknown as sdkClient.DefaultAgentCardResolver,
483+
);
484+
456485
await registry.initialize();
457486

458-
// Verify ackService was called with the URL, not the file hash
487+
// Verify ackService was called with the content-based hash
459488
expect(ackService.isAcknowledged).toHaveBeenCalledWith(
460489
expect.anything(),
461490
'RemoteAgent',
462-
'https://example.com/card',
491+
expectedHash,
463492
);
464493

465-
// Also verify that the agent's metadata was updated to use the URL as hash
466-
// Use getDefinition because registerAgent might have been called
494+
// Also verify that the agent's metadata was updated to use the content-based hash
467495
expect(registry.getDefinition('RemoteAgent')?.metadata?.hash).toBe(
468-
'https://example.com/card',
496+
expectedHash,
469497
);
470498
});
499+
500+
it('should use safeFetch in DefaultAgentCardResolver during initialization', async () => {
501+
mockConfig = makeMockedConfig({ enableAgents: true });
502+
vi.spyOn(mockConfig, 'isTrustedFolder').mockReturnValue(true);
503+
vi.spyOn(mockConfig, 'getFolderTrust').mockReturnValue(true);
504+
505+
const registry = new TestableAgentRegistry(mockConfig);
506+
507+
const remoteAgent: AgentDefinition = {
508+
kind: 'remote',
509+
name: 'RemoteAgent',
510+
description: 'A remote agent',
511+
agentCardUrl: 'https://example.com/card',
512+
inputConfig: { inputSchema: { type: 'object' } },
513+
};
514+
515+
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockResolvedValue({
516+
agents: [remoteAgent],
517+
errors: [],
518+
});
519+
520+
// Track constructor calls
521+
const resolverMock = vi.mocked(sdkClient.DefaultAgentCardResolver);
522+
523+
await registry.initialize();
524+
525+
// Find the call for our remote agent
526+
const call = resolverMock.mock.calls.find((args) => {
527+
const options = args[0] as { fetchImpl?: typeof fetch };
528+
// We look for a call that was provided with a fetch implementation.
529+
// In our current implementation, we wrap safeFetch.
530+
return typeof options?.fetchImpl === 'function';
531+
});
532+
533+
expect(call).toBeDefined();
534+
const options = call?.[0] as { fetchImpl?: typeof fetch };
535+
536+
// We passed safeFetch directly
537+
expect(options?.fetchImpl).toBe(safeFetch);
538+
});
471539
});
472540

473541
describe('registration logic', () => {
@@ -874,6 +942,17 @@ describe('AgentRegistry', () => {
874942
);
875943
});
876944

945+
it('should maintain registration under canonical name', async () => {
946+
const originalName = 'my-agent';
947+
const definition = { ...MOCK_AGENT_V1, name: originalName };
948+
949+
await registry.testRegisterAgent(definition);
950+
951+
const registered = registry.getDefinition(originalName);
952+
expect(registered).toBeDefined();
953+
expect(registry.getAllAgentNames()).toEqual([originalName]);
954+
});
955+
877956
it('should reject an agent definition missing a name', async () => {
878957
const invalidAgent = { ...MOCK_AGENT_V1, name: '' };
879958
const debugWarnSpy = vi

packages/core/src/agents/registry.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@ import { CoreEvent, coreEvents } from '../utils/events.js';
99
import type { AgentOverride, Config } from '../config/config.js';
1010
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
1111
import { loadAgentsFromDirectory } from './agentLoader.js';
12+
import { splitAgentCardUrl } from './a2aUtils.js';
1213
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
1314
import { CliHelpAgent } from './cli-help-agent.js';
1415
import { GeneralistAgent } from './generalist-agent.js';
1516
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
1617
import { A2AClientManager } from './a2a-client-manager.js';
1718
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
19+
import { DefaultAgentCardResolver } from '@a2a-js/sdk/client';
20+
import { safeFetch } from '../utils/fetch.js';
1821
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
1922
import { type z } from 'zod';
23+
import * as crypto from 'node:crypto';
2024
import { debugLogger } from '../utils/debugLogger.js';
2125
import { isAutoModel } from '../config/models.js';
2226
import {
@@ -155,13 +159,38 @@ export class AgentRegistry {
155159
const agentsToRegister: AgentDefinition[] = [];
156160

157161
for (const agent of projectAgents.agents) {
158-
// If it's a remote agent, use the agentCardUrl as the hash.
159-
// This allows multiple remote agents in a single file to be tracked independently.
162+
// For remote agents, we must use a content-based hash of the AgentCard
163+
// to prevent Indirect Prompt Injection if the remote card is modified.
160164
if (agent.kind === 'remote') {
161-
if (!agent.metadata) {
162-
agent.metadata = {};
165+
try {
166+
// We use a dedicated resolver here to fetch the card for hashing.
167+
// This is separate from loadAgent to keep hashing logic isolated.
168+
// We provide safeFetch to ensure SSRF and DNS rebinding protection.
169+
const resolver = new DefaultAgentCardResolver({
170+
fetchImpl: safeFetch,
171+
});
172+
const { baseUrl, path } = splitAgentCardUrl(agent.agentCardUrl);
173+
const rawCard = await resolver.resolve(baseUrl, path);
174+
const cardContent = JSON.stringify(rawCard);
175+
const contentHash = crypto
176+
.createHash('sha256')
177+
.update(cardContent)
178+
.digest('hex');
179+
180+
if (!agent.metadata) {
181+
agent.metadata = {};
182+
}
183+
// Combining URL and content hash ensures we track specific card versions at specific locations.
184+
agent.metadata.hash = `${agent.agentCardUrl}#${contentHash}`;
185+
} catch (e) {
186+
debugLogger.warn(
187+
`[AgentRegistry] Could not fetch remote card for hashing "${agent.name}":`,
188+
e,
189+
);
190+
// If we can't fetch the card, we can't verify its acknowledgement securely.
191+
unacknowledgedAgents.push(agent);
192+
continue;
163193
}
164-
agent.metadata.hash = agent.agentCardUrl;
165194
}
166195

167196
if (!agent.metadata?.hash) {
@@ -178,6 +207,10 @@ export class AgentRegistry {
178207
if (isAcknowledged) {
179208
agentsToRegister.push(agent);
180209
} else {
210+
// Register unacknowledged agents so they are visible to the LLM.
211+
// They will be registered with ASK_USER policy, triggering the
212+
// acknowledgement flow when the LLM tries to call them.
213+
agentsToRegister.push(agent);
181214
unacknowledgedAgents.push(agent);
182215
}
183216
}
@@ -312,6 +345,17 @@ export class AgentRegistry {
312345
}
313346

314347
const mergedDefinition = this.applyOverrides(definition, settingsOverrides);
348+
349+
// Ensure we don't accidentally overwrite an existing agent with a different canonical name
350+
if (
351+
mergedDefinition.name !== definition.name &&
352+
this.agents.has(mergedDefinition.name)
353+
) {
354+
throw new Error(
355+
`Cannot register agent '${definition.name}' as '${mergedDefinition.name}': Name collision with an already registered agent.`,
356+
);
357+
}
358+
315359
this.agents.set(mergedDefinition.name, mergedDefinition);
316360

317361
this.registerModelConfigs(mergedDefinition);
@@ -339,12 +383,21 @@ export class AgentRegistry {
339383
policyEngine.removeRulesForTool(definition.name, 'AgentRegistry (Dynamic)');
340384

341385
// Add the new dynamic policy
386+
const isAcknowledged =
387+
definition.kind === 'local' &&
388+
(!definition.metadata?.hash ||
389+
(this.config.getProjectRoot() &&
390+
this.config
391+
.getAcknowledgedAgentsService()
392+
?.isAcknowledgedSync?.(
393+
this.config.getProjectRoot(),
394+
definition.name,
395+
definition.metadata.hash,
396+
)));
397+
342398
policyEngine.addRule({
343399
toolName: definition.name,
344-
decision:
345-
definition.kind === 'local'
346-
? PolicyDecision.ALLOW
347-
: PolicyDecision.ASK_USER,
400+
decision: isAcknowledged ? PolicyDecision.ALLOW : PolicyDecision.ASK_USER,
348401
priority: PRIORITY_SUBAGENT_TOOL,
349402
source: 'AgentRegistry (Dynamic)',
350403
});

packages/core/src/agents/registry_acknowledgement.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { coreEvents } from '../utils/events.js';
1212
import * as tomlLoader from './agentLoader.js';
1313
import { type Config } from '../config/config.js';
1414
import { AcknowledgedAgentsService } from './acknowledgedAgents.js';
15+
import { PolicyDecision } from '../policy/types.js';
1516
import * as fs from 'node:fs/promises';
1617
import * as path from 'node:path';
1718
import * as os from 'node:os';
@@ -103,13 +104,22 @@ describe('AgentRegistry Acknowledgement', () => {
103104
await fs.rm(tempDir, { recursive: true, force: true });
104105
});
105106

106-
it('should not register unacknowledged project agents and emit event', async () => {
107+
it('should register unacknowledged project agents and emit event', async () => {
107108
const emitSpy = vi.spyOn(coreEvents, 'emitAgentsDiscovered');
108109

109110
await registry.initialize();
110111

111-
expect(registry.getDefinition('ProjectAgent')).toBeUndefined();
112+
// Now unacknowledged agents ARE registered (but with ASK_USER policy)
113+
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
112114
expect(emitSpy).toHaveBeenCalledWith([MOCK_AGENT_WITH_HASH]);
115+
116+
// Verify policy
117+
const policyEngine = config.getPolicyEngine();
118+
expect(
119+
await policyEngine?.check({ name: 'ProjectAgent', args: {} }, undefined),
120+
).toMatchObject({
121+
decision: PolicyDecision.ASK_USER,
122+
});
113123
});
114124

115125
it('should register acknowledged project agents', async () => {
@@ -134,6 +144,14 @@ describe('AgentRegistry Acknowledgement', () => {
134144

135145
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
136146
expect(emitSpy).not.toHaveBeenCalled();
147+
148+
// Verify policy is ALLOW for acknowledged agent
149+
const policyEngine = config.getPolicyEngine();
150+
expect(
151+
await policyEngine?.check({ name: 'ProjectAgent', args: {} }, undefined),
152+
).toMatchObject({
153+
decision: PolicyDecision.ALLOW,
154+
});
137155
});
138156

139157
it('should register agents without hash (legacy/safe?)', async () => {

0 commit comments

Comments
 (0)