Skip to content

Commit 968200d

Browse files
committed
fix(privileged-context): Reject statements with helpful error message
Models sometimes pass JavaScript statements (const/let/var declarations) to evaluate_chrome_script, which wraps input as return(expression), which causes tool aborts, resulting in slowdown and wasted tokens. Adds isLikelyStatement() validation that detects statement keywords and returns a clear error message suggesting the IIFE workaround. Also updates tool description to warn against statement usage.
1 parent 7bad50e commit 968200d

2 files changed

Lines changed: 109 additions & 1 deletion

File tree

src/tools/privileged-context.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const selectPrivilegedContextTool = {
3535
export const evaluatePrivilegedScriptTool = {
3636
name: 'evaluate_privileged_script',
3737
description:
38-
'Evaluate JavaScript in the current privileged context. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Returns the result of the expression.',
38+
'Evaluate JavaScript in the current privileged context. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Returns the result of the expression. IMPORTANT: Only provide expressions, not statements. Do not use const, let, or var declarations as they will cause syntax errors. For complex logic, wrap in an IIFE: (function() { const x = 1; return x; })()',
3939
inputSchema: {
4040
type: 'object',
4141
properties: {
@@ -48,6 +48,15 @@ export const evaluatePrivilegedScriptTool = {
4848
},
4949
};
5050

51+
/**
52+
* Detects if the input looks like a JavaScript statement rather than an expression.
53+
* Statements like const/let/var declarations cannot be used with return().
54+
*/
55+
export function isLikelyStatement(input: string): boolean {
56+
const trimmed = input.trim();
57+
return /^(const|let|var)\s/.test(trimmed);
58+
}
59+
5160
function formatContextList(contexts: any[]): string {
5261
if (contexts.length === 0) {
5362
return '🔧 No privileged contexts found';
@@ -131,6 +140,16 @@ export async function handleEvaluatePrivilegedScript(args: unknown): Promise<Mcp
131140
throw new Error('expression parameter is required and must be a string');
132141
}
133142

143+
if (isLikelyStatement(expression)) {
144+
return errorResponse(
145+
new Error(
146+
`Cannot evaluate statement: "${expression.substring(0, 50)}${expression.length > 50 ? '...' : ''}". ` +
147+
'This tool expects an expression, not a statement (const/let/var declarations are statements). ' +
148+
'To use statements, wrap them in an IIFE: (function() { const x = 1; return x; })()'
149+
)
150+
);
151+
}
152+
134153
const { getFirefox } = await import('../index.js');
135154
const firefox = await getFirefox();
136155

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Tests for privileged context state consistency
3+
*/
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
6+
describe('Privileged context state consistency', () => {
7+
const mockSetContext = vi.fn();
8+
const mockSwitchToWindow = vi.fn();
9+
const mockExecuteScript = vi.fn();
10+
const mockExecuteAsyncScript = vi.fn();
11+
12+
// Track currentContextId state as the real code would
13+
let mockCurrentContextId: string | null;
14+
const mockSetCurrentContextId = vi.fn((id: string) => {
15+
mockCurrentContextId = id;
16+
});
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
vi.resetModules();
21+
// Start with a content context (user has a normal tab open)
22+
mockCurrentContextId = 'original-content-context';
23+
24+
vi.doMock('../../src/index.js', () => ({
25+
getFirefox: vi.fn().mockResolvedValue({
26+
getDriver: () => ({
27+
switchTo: () => ({
28+
window: mockSwitchToWindow,
29+
}),
30+
setContext: mockSetContext,
31+
executeScript: mockExecuteScript,
32+
executeAsyncScript: mockExecuteAsyncScript,
33+
}),
34+
getCurrentContextId: () => mockCurrentContextId,
35+
setCurrentContextId: mockSetCurrentContextId,
36+
sendBiDiCommand: vi.fn().mockResolvedValue({
37+
contexts: [{ context: 'chrome-context-id', url: 'chrome://browser/content/browser.xhtml' }],
38+
}),
39+
}),
40+
}));
41+
});
42+
43+
it('select_privileged_context should update currentContextId (BUG: it does not)', async () => {
44+
const { handleSelectPrivilegedContext } = await import(
45+
'../../src/tools/privileged-context.js'
46+
);
47+
48+
await handleSelectPrivilegedContext({ contextId: 'chrome-context-id' });
49+
50+
expect(mockSwitchToWindow).toHaveBeenCalledWith('chrome-context-id');
51+
expect(mockSetContext).toHaveBeenCalledWith('chrome');
52+
53+
// BUG: select_privileged_context does NOT call setCurrentContextId
54+
// so currentContextId stays as 'original-content-context'
55+
expect(mockSetCurrentContextId).toHaveBeenCalledWith('chrome-context-id');
56+
});
57+
58+
it('set_firefox_prefs after select_privileged_context should not revert to old context (BUG: it does)', async () => {
59+
const { handleSelectPrivilegedContext } = await import(
60+
'../../src/tools/privileged-context.js'
61+
);
62+
const { handleSetFirefoxPrefs } = await import(
63+
'../../src/tools/firefox-prefs.js'
64+
);
65+
66+
// User selects privileged context
67+
await handleSelectPrivilegedContext({ contextId: 'chrome-context-id' });
68+
69+
// BUG: currentContextId is still 'original-content-context' because
70+
// select_privileged_context never called setCurrentContextId.
71+
// So when set_firefox_prefs saves originalContextId, it gets the wrong value.
72+
mockExecuteScript.mockResolvedValue(undefined);
73+
mockSwitchToWindow.mockClear();
74+
mockSetContext.mockClear();
75+
76+
await handleSetFirefoxPrefs({ prefs: { 'browser.ml.enable': true } });
77+
78+
// The finally block in set_firefox_prefs restores to originalContextId.
79+
// Because currentContextId was never updated, it restores to
80+
// 'original-content-context' — silently undoing the privileged context selection.
81+
const setContextCalls = mockSetContext.mock.calls;
82+
const lastSetContext = setContextCalls[setContextCalls.length - 1];
83+
84+
// BUG: last setContext call is 'content' — it reverted the privileged context
85+
// After fix: the tool should detect we're already in a privileged context
86+
// and restore back to it (or at minimum, currentContextId should be correct)
87+
expect(lastSetContext[0]).not.toBe('content');
88+
});
89+
});

0 commit comments

Comments
 (0)