Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/static_quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ on:
- ".github/workflows/publish_release.yml"
- "packages/**/CHANGELOG.md"

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test_e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ on:
- "packages/**/CHANGELOG.md"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test_unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ on:
default: "main"
type: string

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
Binary file not shown.
Binary file not shown.
48 changes: 46 additions & 2 deletions packages/core/src/__tests__/storage-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,58 @@ describe('createAskableStorageSource', () => {
});

it('reads sessionStorage when specified', async () => {
setItems(sessionStorage, { sessionId: 'xyz' });
setItems(sessionStorage, { viewMode: 'grid' });
const ctx = createAskableContext();
ctx.registerSource('sess', createAskableStorageSource({ storage: 'sessionStorage' }));

const resolved = await ctx.resolveSource('sess');
const data = resolved.data as { storageType: string; items: Record<string, unknown> };
expect(data.storageType).toBe('sessionStorage');
expect(data.items.sessionId).toBe('xyz');
expect(data.items.viewMode).toBe('grid');
ctx.destroy();
});

it('masks secret-looking keys by default', async () => {
setItems(localStorage, {
authToken: 'jwt-abc',
api_key: 'sk-123',
userPassword: 'hunter2',
sessionId: 'xyz',
theme: 'dark',
});
const ctx = createAskableContext();
ctx.registerSource('ls', createAskableStorageSource());

const resolved = await ctx.resolveSource('ls');
const data = resolved.data as { items: Record<string, unknown> };
expect(data.items.authToken).toBe('***');
expect(data.items.api_key).toBe('***');
expect(data.items.userPassword).toBe('***');
expect(data.items.sessionId).toBe('***');
expect(data.items.theme).toBe('dark');
ctx.destroy();
});

it('masks secret-looking keys even when explicitly listed in keys', async () => {
setItems(localStorage, { accessToken: 'abc', theme: 'dark' });
const ctx = createAskableContext();
ctx.registerSource('ls', createAskableStorageSource({ keys: ['accessToken', 'theme'] }));

const resolved = await ctx.resolveSource('ls');
const data = resolved.data as { items: Record<string, unknown> };
expect(data.items.accessToken).toBe('***');
expect(data.items.theme).toBe('dark');
ctx.destroy();
});

it('captures secret-looking keys when maskSensitiveKeys is false', async () => {
setItems(localStorage, { authToken: 'jwt-abc' });
const ctx = createAskableContext();
ctx.registerSource('ls', createAskableStorageSource({ maskSensitiveKeys: false }));

const resolved = await ctx.resolveSource('ls');
const data = resolved.data as { items: Record<string, unknown> };
expect(data.items.authToken).toBe('jwt-abc');
ctx.destroy();
});

Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/storage-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ export interface AskableCreateStorageSourceOptions {
* Matched values are replaced with `"***"`.
*/
maskKeys?: string[];
/**
* Mask values whose keys look secret-bearing — matching token, secret,
* password, auth, jwt, api key, credential, session id, cookie, or private —
* in addition to `maskKeys`. This is a safety net so credentials in
* localStorage are not serialized into AI context by default. Set to `false`
* only when you are certain the captured storage holds no secrets.
* @default true
*/
maskSensitiveKeys?: boolean;
/**
* Custom transformer applied to the captured items before returning.
* Can be used for field-level sanitization or restructuring.
Expand Down Expand Up @@ -77,6 +86,13 @@ function keyMatchesList(key: string, patterns: string[]): boolean {
return patterns.some((p) => matchesGlob(key, p));
}

const SENSITIVE_KEY_PATTERN = /token|secret|passw(or)?d|auth|jwt|api[-_]?key|credential|session[-_]?id|cookie|private/i;

function shouldMask(key: string, maskKeys: string[], maskSensitiveKeys: boolean): boolean {
if (maskKeys.length > 0 && keyMatchesList(key, maskKeys)) return true;
return maskSensitiveKeys && SENSITIVE_KEY_PATTERN.test(key);
}

function parseValue(raw: string | null, parseJSON: boolean): unknown {
if (raw === null) return null;
if (!parseJSON) return raw;
Expand All @@ -96,6 +112,7 @@ function buildSnapshot(
omitKeys = [],
parseJSON = true,
maskKeys = [],
maskSensitiveKeys = true,
sanitize,
} = options;

Expand All @@ -114,7 +131,7 @@ function buildSnapshot(
for (let i = 0; i < store.length; i++) {
const k = store.key(i);
if (k && matchesGlob(k, pattern) && !keyMatchesList(k, omitKeys)) {
items[k] = maskKeys && keyMatchesList(k, maskKeys)
items[k] = shouldMask(k, maskKeys, maskSensitiveKeys)
? '***'
: parseValue(store.getItem(k), parseJSON);
}
Expand All @@ -123,7 +140,7 @@ function buildSnapshot(
if (!keyMatchesList(pattern, omitKeys)) {
const raw = store.getItem(pattern);
if (raw !== null) {
items[pattern] = maskKeys && keyMatchesList(pattern, maskKeys)
items[pattern] = shouldMask(pattern, maskKeys, maskSensitiveKeys)
? '***'
: parseValue(raw, parseJSON);
}
Expand All @@ -134,7 +151,7 @@ function buildSnapshot(
for (let i = 0; i < store.length; i++) {
const k = store.key(i);
if (!k || keyMatchesList(k, omitKeys)) continue;
items[k] = maskKeys && keyMatchesList(k, maskKeys)
items[k] = shouldMask(k, maskKeys, maskSensitiveKeys)
? '***'
: parseValue(store.getItem(k), parseJSON);
}
Expand Down
24 changes: 24 additions & 0 deletions packages/mcp/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,30 @@ describe('createAskableMcpPageBridge', () => {
);
});

it('drops the response instead of posting with a wildcard target origin', async () => {
const packet = createWebContextPacket({ capture: { mode: 'region' } });
const provider: AskableMcpContextProvider = {
getContext: vi.fn().mockResolvedValue(packet),
};
const fakeWindow = new FakePageBridgeWindow();
(fakeWindow as unknown as { location: undefined }).location = undefined;
createAskableMcpPageBridge({
provider,
window: fakeWindow,
allowedOrigins: () => true,
});

fakeWindow.emit({
protocol: ASKABLE_MCP_PAGE_BRIDGE_PROTOCOL,
version: ASKABLE_MCP_PAGE_BRIDGE_VERSION,
type: 'get_current_context',
requestId: 'req-no-origin',
}, '');
await flushPageBridge();

expect(fakeWindow.posted).toHaveLength(0);
});

it('removes the page bridge listener on dispose', async () => {
const provider: AskableMcpContextProvider = {
getContext: vi.fn(),
Expand Down
17 changes: 12 additions & 5 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,9 @@ async function handleAskableMcpPageBridgeMessage(
return;
}

const targetOrigin = resolveAskableMcpPageBridgeTargetOrigin(event, options, bridgeWindow);
if (!targetOrigin) return;

try {
const contextOptions = getAskableMcpPageBridgeContextOptions(request.options);
const packet = await options.provider.getContext(contextOptions);
Expand All @@ -640,7 +643,7 @@ async function handleAskableMcpPageBridgeMessage(
...createAskableMcpPageBridgeResponseBase(request),
type: `${request.type}:error`,
error: { message: 'Context packet has not been redacted. Set requireRedacted: false to allow, or redact the packet before sending.' },
}, resolveAskableMcpPageBridgeTargetOrigin(event, options));
}, targetOrigin);
return;
}

Expand Down Expand Up @@ -672,14 +675,14 @@ async function handleAskableMcpPageBridgeMessage(
};
}

bridgeWindow.postMessage(response, resolveAskableMcpPageBridgeTargetOrigin(event, options));
bridgeWindow.postMessage(response, targetOrigin);
} catch (error) {
options.onError?.(error, event);
bridgeWindow.postMessage({
...createAskableMcpPageBridgeResponseBase(request),
type: `${request.type}:error`,
error: { message: 'Askable MCP page bridge failed.' },
}, resolveAskableMcpPageBridgeTargetOrigin(event, options));
}, targetOrigin);
}
}

Expand Down Expand Up @@ -801,8 +804,12 @@ function createAskableMcpPageBridgeResponseBase(
function resolveAskableMcpPageBridgeTargetOrigin(
event: MessageEvent,
options: AskableMcpPageBridgeOptions,
): string {
return options.targetOrigin ?? (event.origin || '*');
bridgeWindow: AskableMcpPageBridgeWindow,
): string | null {
if (options.targetOrigin) return options.targetOrigin;
// Never fall back to '*': context packets must not be broadcast to
// arbitrary origins. Without a known origin, drop the response instead.
return event.origin || bridgeWindow.location?.origin || null;
}

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down
Loading