feat(core): implement Stage 2 security and consistency improvements for web_fetch (#22217)

This commit is contained in:
Aishanee Shah
2026-03-16 17:38:53 -04:00
committed by GitHub
parent b6c6da3618
commit 990d010ecf
4 changed files with 250 additions and 86 deletions
+14 -14
View File
@@ -497,7 +497,7 @@ describe('WebFetchTool', () => {
expect(result.llmContent).toBe('fallback processed response');
expect(result.returnDisplay).toContain(
'2 URL(s) processed using fallback fetch',
'URL(s) processed using fallback fetch',
);
});
@@ -530,7 +530,7 @@ describe('WebFetchTool', () => {
// Verify private URL was NOT fetched (mockFetch would throw if it was called for private.com)
});
it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => {
it('should return WEB_FETCH_FALLBACK_FAILED on total failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
mockGenerateContent.mockRejectedValue(new Error('primary fail'));
mockFetch('https://public.ip/', new Error('fallback fetch failed'));
@@ -541,16 +541,6 @@ describe('WebFetchTool', () => {
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);
});
it('should return WEB_FETCH_FALLBACK_FAILED on general processing failure (when fallback also fails)', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
mockGenerateContent.mockRejectedValue(new Error('API error'));
const tool = new WebFetchTool(mockConfig, bus);
const params = { prompt: 'fetch https://public.ip' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);
});
it('should log telemetry when falling back due to primary fetch failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
// Mock primary fetch to return empty response, triggering fallback
@@ -639,6 +629,14 @@ describe('WebFetchTool', () => {
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
const sanitizeXml = (text: string) =>
text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
if (shouldConvert) {
expect(convert).toHaveBeenCalledWith(content, {
wordwrap: false,
@@ -647,10 +645,12 @@ describe('WebFetchTool', () => {
{ selector: 'img', format: 'skip' },
],
});
expect(result.llmContent).toContain(`Converted: ${content}`);
expect(result.llmContent).toContain(
`Converted: ${sanitizeXml(content)}`,
);
} else {
expect(convert).not.toHaveBeenCalled();
expect(result.llmContent).toContain(content);
expect(result.llmContent).toContain(sanitizeXml(content));
}
},
);