Compare commits

...
101 changed files with 6428 additions and 169 deletions

No files matched your search

+16
View File
@@ -376,6 +376,22 @@ export const enAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'External MCP help',
'ai.externalMcp.security': 'Security',
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
"ai.privacy.title": "AI privacy",
"ai.privacy.description": "Filters known sensitive text patterns in Netcatty-managed context and tool results for Catty, integrated agents and external MCP clients. Off by default. It cannot detect every secret. Binary tool responses are blocked while enabled.",
"ai.privacy.boundary": "Only Netcatty-managed data and tools are covered. Third-party agents' native tools, direct file access and other MCP servers remain outside this boundary.",
"ai.privacy.enable": "Enable text redaction",
"ai.privacy.privateIps": "Redact private IP addresses",
"ai.privacy.privateIps.description": "Also hide private and local IP addresses in returned text.",
"ai.privacy.cidrs": "Additional IP ranges (CIDR)",
"ai.privacy.cidrs.description": "One IPv4 or IPv6 CIDR per line. Leave empty to use only built-in rules.",
"ai.privacy.patterns": "Custom regular expressions",
"ai.privacy.patterns.description": "One RE2-compatible pattern per line, without / delimiters or flags. Unsupported or unsafe patterns are rejected.",
"ai.privacy.connectionScope": "Placeholders stay stable within one managed conversation or external MCP connection. Original values are restored only through Netcatty-managed tool calls in that same scope.",
"ai.privacy.reconnect": "Apply changes, then start a new managed conversation and reconnect external MCP clients. Existing conversations and connections must not reuse placeholders.",
"ai.privacy.apply": "Apply",
"ai.privacy.applying": "Applying...",
"ai.privacy.applied": "AI privacy settings applied. Start a new managed conversation and reconnect external MCP clients.",
"ai.privacy.failed": "AI privacy settings were not applied.",
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
'ai.externalMcp.http.enable': 'Enable HTTP',
+16
View File
@@ -367,6 +367,22 @@ export const esAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
'ai.externalMcp.security': 'Seguridad',
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
"ai.privacy.title": "Privacidad de IA",
"ai.privacy.description": "Filtra patrones de texto sensibles conocidos en el contexto y los resultados de herramientas gestionados por Netcatty para Catty, agentes integrados y clientes MCP externos. Desactivado de forma predeterminada. No detecta todos los secretos. Las respuestas binarias de herramientas se bloquean mientras está activo.",
"ai.privacy.boundary": "Solo cubre los datos y las herramientas gestionados por Netcatty. Las herramientas nativas de agentes de terceros, su acceso directo a archivos y otros servidores MCP quedan fuera de esta protección.",
"ai.privacy.enable": "Activar ocultación de texto",
"ai.privacy.privateIps": "Ocultar direcciones IP privadas",
"ai.privacy.privateIps.description": "Oculta también las direcciones IP privadas y locales en el texto devuelto.",
"ai.privacy.cidrs": "Rangos IP adicionales (CIDR)",
"ai.privacy.cidrs.description": "Un CIDR IPv4 o IPv6 por línea. Déjalo vacío para usar solo las reglas integradas.",
"ai.privacy.patterns": "Expresiones regulares personalizadas",
"ai.privacy.patterns.description": "Un patrón compatible con RE2 por línea, sin delimitadores / ni indicadores. Los patrones incompatibles o inseguros se rechazan.",
"ai.privacy.connectionScope": "Los marcadores son estables dentro de una conversación gestionada o una conexión MCP externa. Los valores originales solo se restauran mediante llamadas a herramientas gestionadas por Netcatty dentro del mismo ámbito.",
"ai.privacy.reconnect": "Aplica los cambios, inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos. Las conversaciones y conexiones existentes no deben reutilizar marcadores.",
"ai.privacy.apply": "Aplicar",
"ai.privacy.applying": "Aplicando...",
"ai.privacy.applied": "Configuración de privacidad de IA aplicada. Inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos.",
"ai.privacy.failed": "No se aplicó la configuración de privacidad de IA.",
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
'ai.externalMcp.http.enable': 'Activar HTTP',
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import es from './es.ts';
import ru from './ru.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const KEYS = [
'title', 'description', 'boundary', 'enable', 'privateIps', 'privateIps.description',
'cidrs', 'cidrs.description', 'patterns', 'patterns.description',
'connectionScope', 'reconnect', 'apply', 'applying', 'applied', 'failed',
];
test('Shared AI privacy settings are localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
assert.deepEqual(
KEYS.filter((key) => !messages[`ai.privacy.${key}`]),
[],
`${name} is missing privacy settings labels`,
);
}
});
+16
View File
@@ -323,6 +323,22 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
'ai.externalMcp.security': 'Безопасность',
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
"ai.privacy.title": "Конфиденциальность ИИ",
"ai.privacy.description": "Фильтрует известные шаблоны конфиденциального текста в контексте и результатах инструментов, управляемых Netcatty, для Catty, встроенных агентов и внешних MCP-клиентов. По умолчанию отключено. Не обнаруживает все секреты. При включении двоичные ответы инструментов блокируются.",
"ai.privacy.boundary": "Защищаются только данные и инструменты, управляемые Netcatty. Собственные инструменты сторонних агентов, их прямой доступ к файлам и другие MCP-серверы не входят в эту область.",
"ai.privacy.enable": "Включить скрытие текста",
"ai.privacy.privateIps": "Скрывать частные IP-адреса",
"ai.privacy.privateIps.description": "Также скрывает частные и локальные IP-адреса в возвращаемом тексте.",
"ai.privacy.cidrs": "Дополнительные диапазоны IP (CIDR)",
"ai.privacy.cidrs.description": "Один CIDR IPv4 или IPv6 на строку. Оставьте пустым для использования только встроенных правил.",
"ai.privacy.patterns": "Пользовательские регулярные выражения",
"ai.privacy.patterns.description": "Один RE2-совместимый шаблон на строку, без разделителей / и флагов. Неподдерживаемые или небезопасные шаблоны отклоняются.",
"ai.privacy.connectionScope": "Маркеры постоянны в пределах одного управляемого диалога или внешнего MCP-подключения. Исходные значения восстанавливаются только через вызовы инструментов, управляемых Netcatty, в той же области.",
"ai.privacy.reconnect": "Примените изменения, начните новый управляемый диалог и переподключите внешние MCP-клиенты. Существующие диалоги и подключения не должны повторно использовать маркеры.",
"ai.privacy.apply": "Применить",
"ai.privacy.applying": "Применение...",
"ai.privacy.applied": "Настройки конфиденциальности ИИ применены. Начните новый управляемый диалог и переподключите внешние MCP-клиенты.",
"ai.privacy.failed": "Настройки конфиденциальности ИИ не применены.",
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
'ai.externalMcp.http.enable': 'Включить HTTP',
+16
View File
@@ -376,6 +376,22 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
"ai.privacy.title": "AI 隐私",
"ai.privacy.description": "为 Catty、集成 Agent 和外部 MCP 客户端过滤 Netcatty 管理的上下文及工具结果中已知的敏感文本模式。默认关闭,无法识别所有机密。启用时会阻止二进制工具响应。",
"ai.privacy.boundary": "仅覆盖 Netcatty 管理的数据和工具。第三方 Agent 的原生工具、直接读取的文件及其他 MCP 服务器不在保护范围内。",
"ai.privacy.enable": "启用文本脱敏",
"ai.privacy.privateIps": "隐藏私有 IP 地址",
"ai.privacy.privateIps.description": "同时隐藏返回文本中的私有和本地 IP 地址。",
"ai.privacy.cidrs": "附加 IP 网段(CIDR)",
"ai.privacy.cidrs.description": "每行一个 IPv4 或 IPv6 CIDR。留空则仅使用内置规则。",
"ai.privacy.patterns": "自定义正则表达式",
"ai.privacy.patterns.description": "每行一个兼容 RE2 的模式,不含 / 分隔符或标志。不支持或不安全的模式会被拒绝。",
"ai.privacy.connectionScope": "占位符在同一个受管对话或外部 MCP 连接内保持稳定。只有同一范围内的 Netcatty 受管工具调用才能还原原始值。",
"ai.privacy.reconnect": "应用更改后,请新建受管对话并重新连接外部 MCP 客户端。已有对话和连接不得复用占位符。",
"ai.privacy.apply": "应用",
"ai.privacy.applying": "正在应用...",
"ai.privacy.applied": "AI 隐私设置已应用。请新建受管对话并重新连接外部 MCP 客户端。",
"ai.privacy.failed": "AI 隐私设置未应用。",
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
'ai.externalMcp.http.enable': '启用 HTTP',
+16
View File
@@ -366,6 +366,22 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
"ai.privacy.title": "AI 隱私",
"ai.privacy.description": "為 Catty、整合 Agent 和外部 MCP 用戶端過濾 Netcatty 管理的上下文及工具結果中已知的敏感文字模式。預設關閉,無法識別所有機密。啟用時會封鎖二進位工具回應。",
"ai.privacy.boundary": "僅涵蓋 Netcatty 管理的資料和工具。第三方 Agent 的原生工具、直接讀取的檔案及其他 MCP 伺服器不在保護範圍內。",
"ai.privacy.enable": "啟用文字去識別化",
"ai.privacy.privateIps": "隱藏私有 IP 位址",
"ai.privacy.privateIps.description": "同時隱藏回傳文字中的私有和本機 IP 位址。",
"ai.privacy.cidrs": "額外 IP 網段(CIDR)",
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
"ai.privacy.patterns": "自訂正規表示式",
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
"ai.privacy.apply": "套用",
"ai.privacy.applying": "正在套用...",
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
"ai.privacy.failed": "AI 隱私設定未套用。",
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
'ai.externalMcp.http.enable': '啟用 HTTP',
@@ -1,10 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
import {
hasReversibleModelPrivacy,
setReversibleModelPrivacy,
} from '../../infrastructure/ai/harness/modelSecretRedaction';
import type { AISession } from '../../infrastructure/ai/types';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
cleanupClosedTerminalSessions,
cleanupDeletedAIChatSessions,
cleanupOrphanedAISessions,
cleanupSdkAgentSessions,
latestAIActiveSessionMapSnapshot,
latestAISessionsSnapshot,
setLatestAIActiveSessionMapSnapshot,
setLatestAISessionsSnapshot,
} from './aiStateSnapshots';
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
@@ -61,6 +72,92 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
} as never;
}
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
const service = createAiPrivacyService({ maxSessions: 3 });
service.configure({ enabled: true });
const sessions = [
{ ...makeSession('privacy-orphan', 1, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
{ ...makeSession('privacy-live', 2, []), scope: { type: 'terminal' as const, targetId: 'open' } },
{ ...makeSession('privacy-resumed', 3, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
];
const aliases = sessions.map(session => {
setReversibleModelPrivacy(session.id, true);
return service.redact(session.id, '8.8.8.8');
});
const sdkCleanups: string[] = [];
const privacyCleanups: string[] = [];
const outputCleanups: string[] = [];
const previousWindow = globalThis.window;
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
t.mock.method(localStorageAdapter, 'write', () => true);
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
dispatchEvent: () => true,
netcatty: {
aiSdkAgentCleanup: async (id: string) => {
sdkCleanups.push(id);
return { ok: true };
},
aiPrivacyClearSession: async (id: string) => {
privacyCleanups.push(id);
service.clearSession(id);
return { ok: true };
},
deleteChatToolOutputsTemp: async (id: string) => {
outputCleanups.push(id);
return { deletedCount: 1 };
},
},
},
});
setLatestAISessionsSnapshot(sessions);
setLatestAIActiveSessionMapSnapshot({
'terminal:closed': 'privacy-orphan',
'terminal:open': 'privacy-live',
'terminal:resumed-into': 'privacy-resumed',
});
try {
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
assert.equal(latestAISessionsSnapshot, sessions);
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
'terminal:open': 'privacy-live',
'terminal:resumed-into': 'privacy-resumed',
});
assert.deepEqual(sdkCleanups, ['privacy-orphan']);
assert.deepEqual(privacyCleanups, []);
assert.deepEqual(outputCleanups, []);
for (const [index, session] of sessions.entries()) {
assert.equal(hasReversibleModelPrivacy(session.id), true);
assert.equal(service.redact(session.id, '8.8.8.8'), aliases[index]);
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
}
assert.throws(() => service.checkSession('privacy-next'), /session limit reached/);
cleanupDeletedAIChatSessions(['privacy-orphan']);
assert.deepEqual(privacyCleanups, ['privacy-orphan']);
assert.deepEqual(outputCleanups, ['privacy-orphan']);
assert.equal(hasReversibleModelPrivacy('privacy-orphan'), false);
assert.equal(service.hasSession('privacy-orphan'), false);
assert.throws(() => service.checkSession('privacy-orphan'), /start a new chat/);
assert.equal(service.checkSession('privacy-next'), true);
for (const [index, session] of sessions.entries()) {
if (session.id === 'privacy-orphan') continue;
assert.equal(hasReversibleModelPrivacy(session.id), true);
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
}
} finally {
for (const session of sessions) setReversibleModelPrivacy(session.id, false);
setLatestAISessionsSnapshot(previousSessions ?? []);
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_PRIVACY, STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_MODE } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
readExternalMcpHttpAllowCustomBind,
@@ -16,6 +16,10 @@ import {
} from "./useExternalMcpToggleState.ts";
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
applyAIPrivacySettings,
readAIPrivacySettings,
applyExternalMcpRedactionSettings,
readExternalMcpRedactionSettings,
bumpExternalMcpEnableGenerationForTests,
createExternalMcpStartupSyncPlan,
getExternalMcpEnableGenerationForTests,
@@ -116,11 +120,12 @@ describe('useExternalMcpToggleState helpers', () => {
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
received.push(config.sessionIdleTimeoutMinutes);
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
return { ok: true };
},
};
await syncExternalMcpStartupState(bridge);
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
syncExternalMcpConfig(bridge);
await syncExternalMcpConfig(bridge);
assert.deepEqual(received, [expected, expected]);
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
assert.equal(
@@ -452,3 +457,334 @@ describe("external MCP HTTP settings", () => {
}
});
});
describe('Shared AI privacy settings', () => {
const settings = {
enabled: true,
redactPrivateIps: true,
cidrs: ['192.0.2.0/24'],
patterns: ['INTERNAL_[A-Z0-9]+'],
};
it('defaults off and persists only after successful bridge validation', async () => {
const restore = installMemoryLocalStorage();
try {
assert.deepEqual(readExternalMcpRedactionSettings(), {
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
});
let resolveConfig: (value: unknown) => void;
const apply = applyExternalMcpRedactionSettings(settings, {
externalMcpSetConfig: async (config) => {
assert.deepEqual(config, { redaction: settings });
return new Promise((resolve) => { resolveConfig = resolve; });
},
});
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null);
resolveConfig!({ ok: true });
await apply;
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), settings);
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), null);
} finally {
restore();
}
});
it('reads legacy privacy preferences until a validated shared preference is applied', async () => {
const restore = installMemoryLocalStorage();
try {
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
assert.deepEqual(readAIPrivacySettings(), settings);
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null, 'reads must not migrate or discard data');
const changed = { ...settings, redactPrivateIps: false };
await applyAIPrivacySettings(changed, { externalMcpSetConfig: async () => ({ ok: true }) });
assert.deepEqual(readAIPrivacySettings(), changed);
assert.deepEqual(readExternalMcpRedactionSettings(), changed, 'legacy API must read the same shared policy');
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), changed);
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), settings, 'keep legacy storage intact');
} finally {
restore();
}
});
it('never falls back to legacy settings when a shared preference exists, even if corrupt', () => {
const restore = installMemoryLocalStorage();
try {
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
const disabled = { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, disabled);
assert.deepEqual(readAIPrivacySettings(), disabled);
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
localStorageAdapter.remove(STORAGE_KEY_AI_PRIVACY);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, '{');
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
} finally {
restore();
}
});
it('restores the shared policy even when External MCP is disabled', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, false);
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
const calls: string[] = [];
const plan = await syncExternalMcpStartupState({
externalMcpSetConfig: async (config) => {
assert.deepEqual(config.redaction, settings);
calls.push('shared-policy');
return { ok: true };
},
externalMcpSetEnabled: async (enabled) => { calls.push(`external:${enabled}`); },
});
assert.deepEqual(calls, ['shared-policy', 'external:false']);
assert.equal(plan.runtimeEnabled, false);
assert.deepEqual(readAIPrivacySettings(), settings);
} finally {
restore();
}
});
it('does not persist rejected, unverified or failed privacy changes', async () => {
const restore = installMemoryLocalStorage();
try {
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
const changed = { ...settings, patterns: ['['] };
for (const result of [{ ok: false, error: 'Invalid regular expression' }, { ok: false, error: '' }, undefined, {}]) {
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
externalMcpSetConfig: async () => result,
}));
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
}
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
}), /Disconnected/);
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
} finally {
restore();
}
});
it('syncs saved privacy settings before persistent startup enable', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
const calls: string[] = [];
await syncExternalMcpStartupState({
externalMcpSetConfig: async (config) => {
assert.deepEqual(config.redaction, settings);
calls.push('config');
return { ok: true };
},
externalMcpSetEnabled: async (enabled) => { calls.push(`enabled:${enabled}`); },
});
assert.deepEqual(calls, ['config', 'enabled:true']);
} finally {
restore();
}
});
it('fails closed at startup when saved privacy settings are malformed', async () => {
const restore = installMemoryLocalStorage();
try {
for (const saved of ['{', 'null', '{}', '{"enabled":"true"}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[2]}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[],"unexpected":true}']) {
resetExternalMcpStartupReadyForTests();
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, saved);
const enabledCalls: boolean[] = [];
let configCalls = 0;
const bridge = {
externalMcpSetConfig: async (config: unknown) => {
assert.deepEqual(config, { redaction: null });
configCalls += 1;
return { ok: false, error: 'Shared AI privacy configuration is unavailable.' };
},
externalMcpSetEnabled: async (enabled: boolean) => { enabledCalls.push(enabled); },
};
const plan = await syncExternalMcpStartupState(bridge);
assert.equal(plan.runtimeEnabled, false);
assert.match(plan.configError!, /Invalid saved/);
assert.equal(configCalls, 1, 'invalidate shared privacy instead of applying unprotected defaults');
assert.deepEqual(enabledCalls, [false]);
assert.equal(readExternalMcpStoredEnabled(), false);
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved/);
assert.equal(configCalls, 2);
}
} finally {
restore();
}
});
it('fails closed when the backend rejects or cannot verify startup config', async () => {
const restore = installMemoryLocalStorage();
try {
for (const result of [{ ok: false, error: 'Invalid CIDR' }, { ok: false, error: '' }, undefined, {}]) {
resetExternalMcpStartupReadyForTests();
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
const enabledCalls: boolean[] = [];
const plan = await syncExternalMcpStartupState({
externalMcpSetConfig: async () => result,
externalMcpSetEnabled: async (enabled) => { enabledCalls.push(enabled); },
});
assert.equal(plan.runtimeEnabled, false);
assert.ok(plan.configError);
assert.deepEqual(enabledCalls, [false]);
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
}
} finally {
restore();
}
});
it('preserves the corrupt-settings error if shared policy invalidation cannot reach the bridge', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
const calls: boolean[] = [];
const bridge = {
externalMcpSetConfig: async (config: unknown) => {
assert.deepEqual(config, { redaction: null });
throw new Error('Disconnected');
},
externalMcpSetEnabled: async (enabled: boolean) => { calls.push(enabled); },
};
const plan = await syncExternalMcpStartupState(bridge);
assert.match(plan.configError!, /Invalid saved AI privacy settings/);
assert.deepEqual(calls, [false]);
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved AI privacy settings/);
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), '{');
} finally {
restore();
}
});
it('does not enable from a stale privacy snapshot changed during config sync', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
const calls: boolean[] = [];
const plan = await syncExternalMcpStartupState({
externalMcpSetConfig: async () => {
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
return { ok: true };
},
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
});
assert.deepEqual(calls, [false]);
assert.match(plan.configError!, /changed during startup/);
await assert.rejects(syncExternalMcpConfig({
externalMcpSetConfig: async () => {
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, { ...settings, cidrs: [] });
return { ok: true };
},
}), /settings changed/);
} finally {
restore();
}
});
it('fails closed on a startup config transport failure', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
const calls: boolean[] = [];
const plan = await syncExternalMcpStartupState({
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
});
assert.deepEqual(calls, [false]);
assert.equal(plan.configError, 'Disconnected');
} finally {
restore();
}
});
});
describe('External MCP manual enable privacy gate', () => {
it('waits for validated config and keeps rejected or superseded enables off', async () => {
const React = await import('react');
const { act, create } = await import('react-test-renderer');
const { useExternalMcpToggleState } = await import('./useExternalMcpToggleState.ts');
const restoreStorage = installMemoryLocalStorage();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
const events = new EventTarget();
let resolveConfig!: (value: unknown) => void;
const calls: string[] = [];
const bridge = {
externalMcpSetConfig: async () => {
calls.push('config');
return new Promise((resolve) => { resolveConfig = resolve; });
},
externalMcpSetEnabled: async (enabled: boolean) => {
calls.push(`enabled:${enabled}`);
return { ok: true, enabled };
},
};
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
Object.defineProperty(globalThis, 'window', {
value: {
netcatty: bridge,
location: { hash: '#/settings' },
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events),
setInterval,
clearInterval,
},
configurable: true,
});
let hook!: ReturnType<typeof useExternalMcpToggleState>;
const Capture = () => { hook = useExternalMcpToggleState(); return null; };
let renderer: ReturnType<typeof create> | undefined;
try {
resetExternalMcpStartupReadyForTests();
await act(async () => { renderer = create(React.createElement(Capture)); });
let pending!: Promise<void>;
await act(async () => { pending = hook.setEnabled(true); });
assert.deepEqual(calls, ['config']);
assert.equal(hook.enabled, false);
assert.equal(readExternalMcpStoredEnabled(), false);
await act(async () => { resolveConfig({ ok: false, error: 'Invalid privacy rules' }); await pending; });
assert.deepEqual(calls, ['config', 'enabled:false']);
assert.equal(hook.enabled, false);
assert.match(hook.error!, /Invalid privacy rules/);
calls.length = 0;
await act(async () => { pending = hook.setEnabled(true); });
await act(async () => { await hook.setEnabled(false); });
await act(async () => { resolveConfig({ ok: true }); await pending; });
assert.deepEqual(calls, ['config', 'enabled:false']);
assert.equal(hook.enabled, false, 'a newer disable wins over a pending enable');
calls.length = 0;
await act(async () => { pending = hook.setEnabled(true); });
await act(async () => { resolveConfig({ ok: true }); await pending; });
assert.deepEqual(calls, ['config', 'enabled:true']);
assert.equal(hook.enabled, true);
assert.equal(readExternalMcpStoredEnabled(), true);
assert.equal(hook.error, null);
} finally {
if (renderer) await act(async () => renderer!.unmount());
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
restoreStorage();
}
});
});
+234 -28
View File
@@ -10,8 +10,10 @@ import {
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_PRIVACY,
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
} from '../../infrastructure/config/storageKeys';
import type { AIPermissionMode } from '../../infrastructure/ai/types';
@@ -39,6 +41,7 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
/** Bumps on every intentional enable/disable so stale startup applies lose. */
let externalMcpEnableGeneration = 0;
let externalMcpConfigError: string | null = null;
function getWindowHash(hash?: string): string {
if (typeof hash === 'string') return hash;
@@ -76,6 +79,7 @@ export function resetExternalMcpStartupReadyForTests(): void {
externalMcpStartupReadyWaitPromise = null;
externalMcpStartupReadyWaitResolve = null;
externalMcpEnableGeneration = 0;
externalMcpConfigError = null;
externalMcpStartupSyncPromise = null;
}
@@ -110,6 +114,138 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
return externalMcpStartupReadyWaitPromise;
}
export type AIPrivacySettings = {
enabled: boolean;
redactPrivateIps: boolean;
cidrs: string[];
patterns: string[];
};
export function defaultAIPrivacySettings(): AIPrivacySettings {
return { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
}
/** Missing settings are opt-out; corrupt settings must never silently opt out. */
export function readAIPrivacySettings(): AIPrivacySettings {
// The shared key wins, including invalid data: never fall back to a weaker legacy policy.
// Leave the legacy value intact for older installs; Apply writes only the validated shared key.
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY)
?? localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION);
if (stored === null) return defaultAIPrivacySettings();
let value: unknown;
try {
value = JSON.parse(stored);
} catch {
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
}
const settings = value as Record<string, unknown>;
if (
Object.keys(settings).some((key) => !['enabled', 'redactPrivateIps', 'cidrs', 'patterns'].includes(key))
|| typeof settings.enabled !== 'boolean'
|| typeof settings.redactPrivateIps !== 'boolean'
|| !Array.isArray(settings.cidrs) || !settings.cidrs.every((item) => typeof item === 'string')
|| !Array.isArray(settings.patterns) || !settings.patterns.every((item) => typeof item === 'string')
) {
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
}
return {
enabled: settings.enabled,
redactPrivateIps: settings.redactPrivateIps,
cidrs: [...settings.cidrs],
patterns: [...settings.patterns],
};
}
function configResponseError(
result: unknown,
unverifiedMessage = 'External MCP settings could not be verified. The bridge has not been enabled.',
): string | null {
if (result && typeof result === 'object' && 'ok' in result && result.ok === true) return null;
if (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) {
return result.error;
}
return unverifiedMessage;
}
/** Validate in the main process before committing any privacy preference. */
export async function applyAIPrivacySettings(
settings: AIPrivacySettings,
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
): Promise<void> {
const result = await bridge?.externalMcpSetConfig?.({ redaction: settings });
const error = configResponseError(result, 'AI privacy settings could not be verified. Try applying them again.');
if (error) throw new Error(error);
if (!localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings)) {
throw new Error('AI privacy settings could not be saved. Apply them again before restarting.');
}
externalMcpConfigError = null;
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
}
function readAIPrivacyState() {
try {
return { settings: readAIPrivacySettings(), error: null as string | null };
} catch (error) {
return {
settings: defaultAIPrivacySettings(),
error: error instanceof Error ? error.message : String(error),
};
}
}
export function useAIPrivacyState() {
const [state, setState] = useState(readAIPrivacyState);
const [applying, setApplying] = useState(false);
const apply = useCallback(async (settings: AIPrivacySettings): Promise<boolean> => {
setApplying(true);
try {
await applyAIPrivacySettings(settings);
setState({ settings, error: null });
return true;
} catch (error) {
setState((current) => ({
...current,
error: error instanceof Error ? error.message : String(error),
}));
return false;
} finally {
setApplying(false);
}
}, []);
useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY_AI_PRIVACY || event.key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
setState(readAIPrivacyState());
}
};
const onLocalChange = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === STORAGE_KEY_AI_PRIVACY || key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
setState(readAIPrivacyState());
}
};
window.addEventListener('storage', onStorage);
window.addEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
return () => {
window.removeEventListener('storage', onStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
};
}, []);
return { ...state, applying, apply };
}
/** Compatibility exports for existing External MCP configuration callers. One shared policy only. */
export type ExternalMcpRedactionSettings = AIPrivacySettings;
export const defaultExternalMcpRedactionSettings = defaultAIPrivacySettings;
export const readExternalMcpRedactionSettings = readAIPrivacySettings;
export const applyExternalMcpRedactionSettings = applyAIPrivacySettings;
export const useExternalMcpRedactionState = useAIPrivacyState;
export type ExternalMcpHttpSettings = {
enabled: boolean;
port: number;
@@ -119,9 +255,11 @@ export type ExternalMcpHttpSettings = {
};
type ExternalMcpConfig = {
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
mode?: ExternalMcpMode;
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
/** null invalidates shared privacy when persisted settings cannot be read safely. */
redaction?: AIPrivacySettings | null;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
@@ -146,6 +284,7 @@ export type ExternalMcpStartupSyncPlan = {
runtimeEnabled: boolean;
storedEnabled: boolean;
shouldPersistStoredEnabled: boolean;
configError?: string;
};
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
@@ -385,6 +524,7 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
redaction = defaultAIPrivacySettings(),
http = {
enabled: false,
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
@@ -398,6 +538,7 @@ export function createExternalMcpStartupSyncPlan({
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
http?: ExternalMcpHttpSettings;
redaction?: AIPrivacySettings;
}): ExternalMcpStartupSyncPlan {
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
const storedEnabled = runtimeEnabled;
@@ -406,6 +547,7 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
redaction,
...httpConfigFromSettings(http),
},
runtimeEnabled,
@@ -415,23 +557,44 @@ export function createExternalMcpStartupSyncPlan({
}
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
return createExternalMcpStartupSyncPlan({
let redaction = defaultAIPrivacySettings();
let configError: string | undefined;
try {
redaction = readAIPrivacySettings();
} catch (error) {
configError = error instanceof Error ? error.message : String(error);
}
const plan = createExternalMcpStartupSyncPlan({
enabled: readExternalMcpStoredEnabled(),
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
http: readExternalMcpHttpSettings(),
redaction,
});
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
}
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
const http = readExternalMcpHttpSettings();
void bridge?.externalMcpSetConfig?.({
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
...httpConfigFromSettings(http),
});
export async function syncExternalMcpConfig(
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
): Promise<void> {
const plan = readExternalMcpStartupSyncPlan();
if (plan.configError) {
// A corrupt saved policy must block all managed agents, not only External MCP.
// Keep the original recovery error even if notifying the main process fails.
try {
await bridge?.externalMcpSetConfig?.({ redaction: null });
} catch {
// The saved policy still needs user repair regardless of bridge availability.
}
throw new Error(plan.configError);
}
const result = await bridge?.externalMcpSetConfig?.(plan.config);
const error = configResponseError(result);
if (error) throw new Error(error);
if (JSON.stringify(readAIPrivacySettings()) !== JSON.stringify(plan.config.redaction)) {
throw new Error('AI privacy settings changed. Try enabling the bridge again.');
}
}
/**
@@ -453,10 +616,18 @@ export async function syncExternalMcpStartupState(
} catch {
// Permission sync is best-effort; the main process keeps its safe default.
}
let configError = initialPlan.configError;
try {
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
} catch {
// Config sync is best-effort; continue with enable/disable reconcile.
if (configError) {
// Explicit invalidation is distinct from a rejected draft Apply, which keeps
// the previous working policy. Never replace corrupt saved data with defaults.
await Promise.resolve(bridge?.externalMcpSetConfig?.({ redaction: null }));
} else {
const result = await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
configError = configResponseError(result) ?? undefined;
}
} catch (error) {
configError ??= error instanceof Error ? error.message : String(error);
}
// A user toggle during config await invalidates this startup apply.
@@ -465,6 +636,18 @@ export async function syncExternalMcpStartupState(
}
const plan = readExternalMcpStartupSyncPlan();
if (JSON.stringify(plan.config.redaction) !== JSON.stringify(initialPlan.config.redaction)) {
configError = 'AI privacy settings changed during startup. Enable the bridge again.';
}
if (configError || plan.configError) {
// A rejected or unverified config must never expose an unprotected runtime.
plan.configError = configError || plan.configError;
plan.runtimeEnabled = false;
plan.storedEnabled = false;
plan.shouldPersistStoredEnabled = readExternalMcpStoredEnabled();
}
externalMcpConfigError = plan.configError ?? null;
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
if (plan.shouldPersistStoredEnabled) {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
@@ -516,24 +699,47 @@ export function useExternalMcpToggleState() {
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
}, []);
const setEnabled = useCallback((nextEnabled: boolean) => {
persistEnabled(nextEnabled);
// Peer session windows can mirror the stored switch, but they must not own
// the main-process External MCP lifecycle.
if (isPeerSessionWindow) return;
externalMcpEnableGeneration += 1;
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
// event, which can still observe the previous enabled state.
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
const [error, setError] = useState<string | null>(() => externalMcpConfigError);
const setEnabled = useCallback(async (nextEnabled: boolean) => {
// Peer session windows mirror storage, but never own the main lifecycle.
if (isPeerSessionWindow) {
persistEnabled(nextEnabled);
return;
}
if (!nextEnabled) persistEnabled(false);
const generation = ++externalMcpEnableGeneration;
setError(null);
externalMcpConfigError = null;
const bridge = netcattyBridge.get();
try {
// Even a manual enable must apply and verify saved privacy settings first.
if (nextEnabled) await syncExternalMcpConfig(bridge);
if (generation !== externalMcpEnableGeneration) return;
const result = await bridge?.externalMcpSetEnabled?.(nextEnabled);
if (nextEnabled && result && typeof result === 'object' && 'ok' in result && result.ok === false) {
throw new Error('error' in result && typeof result.error === 'string' && result.error
? result.error : 'External MCP could not be enabled.');
}
if (generation === externalMcpEnableGeneration) persistEnabled(nextEnabled);
} catch (cause) {
if (generation !== externalMcpEnableGeneration) return;
externalMcpConfigError = cause instanceof Error ? cause.message : String(cause);
setError(externalMcpConfigError);
if (nextEnabled) {
persistEnabled(false);
await Promise.resolve(bridge?.externalMcpSetEnabled?.(false)).catch(() => undefined);
}
} finally {
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
});
}
}, [isPeerSessionWindow, persistEnabled]);
useEffect(() => {
const syncFromStorage = () => {
const nextEnabled = readExternalMcpStoredEnabled();
setEnabledRaw(nextEnabled);
setError(externalMcpConfigError);
};
const handleStorage = (event: StorageEvent) => {
@@ -542,7 +748,7 @@ export function useExternalMcpToggleState() {
};
const handleLocalStateChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
syncFromStorage();
};
@@ -587,5 +793,5 @@ export function useExternalMcpToggleState() {
};
}, [enabled, isPeerSessionWindow, persistEnabled]);
return { enabled, setEnabled };
return { enabled, setEnabled, error };
}
@@ -51,6 +51,7 @@ import { CopilotCliCard } from "./ai/CopilotCliCard";
import { CodebuddyCard } from "./ai/CodebuddyCard";
import { SafetySettings } from "./ai/SafetySettings";
import { ExternalMcpCard } from "./ai/ExternalMcpCard";
import { AIPrivacySection } from "./ai/AIPrivacySection";
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
@@ -1297,6 +1298,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</SettingCard>
</SettingsSection>
<AIPrivacySection />
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
<ExternalMcpCard />
</SettingsSection>
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
import { AIPrivacySection } from './AIPrivacySection';
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
const values = new Map<string, string>();
const events = new EventTarget();
const globals = {
IS_REACT_ACT_ENVIRONMENT: true,
localStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
window: {
netcatty: { externalMcpSetConfig: setConfig },
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events),
},
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries(globals)) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
}
return {
values,
restore: () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
}
function view() {
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
}
test('privacy settings default off and explain their limits and connection scope', async () => {
const env = installEnvironment(async () => ({ ok: true }));
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(view()); });
const markup = JSON.stringify(renderer!.toJSON());
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
assert.match(markup, /cannot detect every secret/);
assert.match(markup, /AI privacy/);
assert.match(markup, /Catty, integrated agents and external MCP clients/);
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
assert.match(markup, /native tools, direct file access and other MCP servers/);
assert.match(markup, /Binary tool responses are blocked/);
assert.match(markup, /one managed conversation or external MCP connection/);
assert.match(markup, /reconnect external MCP clients/);
assert.match(markup, /start a new managed conversation/);
assert.match(markup, /Additional IP ranges/);
assert.match(markup, /Custom regular expressions/);
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
});
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
});
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
const calls: unknown[] = [];
let accepted = false;
const env = installEnvironment(async (config) => {
calls.push(config);
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
});
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(view()); });
const root = renderer!.root;
const privacySwitch = () => root.findAllByType('button').find((node) => (
node.props['aria-label'] === 'Enable text redaction'
))!;
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
await act(async () => { privacySwitch().props.onClick(); });
await act(async () => {
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
});
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
await act(async () => { apply().props.onClick(); });
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
accepted = true;
await act(async () => {
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
});
await act(async () => { apply().props.onClick(); });
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
assert.deepEqual(saved, {
enabled: true,
redactPrivateIps: false,
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
patterns: ['INTERNAL_[A-Z0-9]+'],
});
assert.deepEqual(calls[1], { redaction: saved });
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
});
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
const env = installEnvironment(async () => ({ ok: true }));
let renderer: ReactTestRenderer | undefined;
try {
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
await act(async () => { renderer = create(view()); });
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
});
test('privacy apply disables draft controls until shared validation settles', async () => {
let resolveConfig!: (result: unknown) => void;
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(view()); });
const root = renderer!.root;
await act(async () => {
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
});
await act(async () => {
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
});
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
await act(async () => { resolveConfig({ ok: true }); });
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
});
test('shared privacy controls refresh when another window changes the shared preference', async () => {
const env = installEnvironment(async () => ({ ok: true }));
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(view()); });
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
const event = new Event('storage');
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
await act(async () => { window.dispatchEvent(event); });
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
});
@@ -0,0 +1,105 @@
import React, { useEffect, useState } from "react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
import { Button } from "../../../ui/button";
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
export const AIPrivacySection: React.FC = () => {
const { t } = useI18n();
const { settings, error, applying, apply } = useAIPrivacyState();
const [enabled, setEnabled] = useState(settings.enabled);
const [privateIps, setPrivateIps] = useState(settings.redactPrivateIps);
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
const [applied, setApplied] = useState(false);
useEffect(() => {
setEnabled(settings.enabled);
setPrivateIps(settings.redactPrivateIps);
setCidrs(settings.cidrs.join("\n"));
setPatterns(settings.patterns.join("\n"));
}, [settings]);
const onApply = async () => {
setApplied(false);
const saved = await apply({
enabled,
redactPrivateIps: privateIps,
cidrs: cidrs.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
});
setApplied(saved);
};
return (
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow label={t("ai.privacy.enable")}>
<Toggle
checked={enabled}
onChange={(next) => { setEnabled(next); setApplied(false); }}
ariaLabel={t("ai.privacy.enable")}
disabled={applying}
/>
</SettingRow>
<SettingRow
label={t("ai.privacy.privateIps")}
description={t("ai.privacy.privateIps.description")}
>
<Toggle
checked={privateIps}
onChange={(next) => { setPrivateIps(next); setApplied(false); }}
ariaLabel={t("ai.privacy.privateIps")}
disabled={applying}
/>
</SettingRow>
</SettingCard>
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
<label className="block space-y-1.5">
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
<textarea
value={cidrs}
onChange={(event) => { setCidrs(event.currentTarget.value); setApplied(false); }}
aria-label={t("ai.privacy.cidrs")}
placeholder={"192.0.2.0/24\n2001:db8::/32"}
rows={3}
spellCheck={false}
disabled={applying}
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
<textarea
value={patterns}
onChange={(event) => { setPatterns(event.currentTarget.value); setApplied(false); }}
aria-label={t("ai.privacy.patterns")}
placeholder="INTERNAL_[A-Z0-9]+"
rows={3}
spellCheck={false}
disabled={applying}
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</label>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
</Button>
</div>
{error ? (
<p role="alert" className="text-xs text-destructive">
{t("ai.privacy.failed")} {error}
</p>
) : applied ? (
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
) : null}
</div>
</SettingsSection>
);
};
@@ -252,7 +252,7 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
export const ExternalMcpCard: React.FC = () => {
const { t } = useI18n();
const { enabled, setEnabled } = useExternalMcpToggleState();
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
@@ -681,8 +681,7 @@ export const ExternalMcpCard: React.FC = () => {
checked={enabled}
onChange={(nextEnabled) => {
setActionMessage(null);
setEnabled(nextEnabled);
window.setTimeout(() => { void refreshStatus(); }, 0);
void setEnabled(nextEnabled).then(() => refreshStatus());
}}
/>
</div>
@@ -989,8 +988,8 @@ export const ExternalMcpCard: React.FC = () => {
{actionMessage.text}
</div>
) : null}
{status?.error ? (
<div className="text-xs text-destructive">{status.error}</div>
{configError || status?.error ? (
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
) : null}
</div>
);
+1
View File
@@ -177,6 +177,7 @@ export interface NetcattyAiBridge {
mode?: 'temporary' | 'persistent';
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
redaction?: import("../../../../application/state/useExternalMcpToggleState").ExternalMcpRedactionSettings;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
+66
View File
@@ -0,0 +1,66 @@
# Shared AI privacy and reversible masking
AI privacy masking is **off by default**. Enable it in Settings > AI > Tool Access >
AI privacy. This shared policy applies to Netcatty-managed context and tools used by
Catty, integrated agents and external MCP clients. It is independent of the External
MCP switch. External stdio and Streamable HTTP clients use the same policy, including
for the environment resource.
This is not a system-wide AI traffic filter. Third-party agents' native tools,
direct file access and other MCP servers are outside the Netcatty-managed boundary.
Local terminal display, saved terminal output and actual host data are not changed.
## Working loop
A recognized address or credential in managed context or a tool response becomes an
opaque token, for example `[NC_<session>_IP_1]`. The same exact value has the same token
within its managed conversation or external MCP connection. A later managed command
or file-write argument containing that token is restored locally before permission
checks and the approval dialog. The host receives the original value; the agent
receives masked managed results.
Mappings stay in memory and are isolated by managed conversation or external MCP
connection. No mapping is saved to disk or shared through the app-wide external chat
scope. New conversations and reconnects start a new mapping. Unknown, stale, or
other-session tokens are rejected rather than sent to the host.
After changing privacy settings, start a new managed conversation and reconnect
external MCP clients. Existing scopes fail closed instead of silently changing
their protection. Settings apply only after main-process validation succeeds. A
saved legacy External MCP privacy preference is used only if no shared AI privacy
preference exists; applying valid settings saves the shared preference. Corrupt
saved settings must be repaired rather than silently reverting to unprotected
operation.
## What is covered
- Recognized credential assignments, authorization bearer values, URL passwords,
familiar API token forms, structured secret fields and complete private-key blocks.
- IPv4 and IPv6 addresses, with private/local ranges retained by default. Enable
**Redact private IP addresses** to hide them too, or select additional CIDRs.
Documentation/test addresses are also masked.
- Structured host names and user-supplied regular-expression matches.
- Netcatty-managed text context, tool results, nested structured results and errors.
Custom expressions match whole values and use the existing RE2 engine. Unsupported
expressions, malformed CIDRs, or empty matches are rejected. Rules and mapping
sizes are bounded; exhaustion blocks the response instead of evicting active tokens.
Binary/base64 tool attachments are blocked while masking is enabled. This feature
does not scan images, archives, encoded/encrypted content, files accessed directly
by third-party agents, or other MCP servers' results. It cannot guarantee detection
of arbitrary credentials, all hostnames, or intentionally obfuscated data. It is an
accidental-disclosure safeguard, not a DLP system or a substitute for the normal
tool permission policy.
## Verification
Keep External MCP disabled and enable AI privacy. Use synthetic values in a new
Catty conversation and a new integrated-agent conversation, then repeat through
external stdio and HTTP clients after enabling External MCP: read `192.0.2.10`, read
`198.51.100.7`, then read the first again. Tokens should be different for different
values and stable for repeated reads within the same scope. Write a returned token
back through a managed tool or use it in a managed terminal command; the actual
file/terminal should contain the original value, never the token. A token from
another conversation/client or an earlier scope must fail without dispatching a
command. No protection should be implied for third-party native tool/file access.
+2
View File
@@ -17,6 +17,7 @@ const { appendVaultAgentGuidance, VAULT_NOTES_CLI_GUIDANCE } = require("../share
const mcpServerBridge = require("./mcpServerBridge.cjs");
const { createExternalMcpController } = require("./externalMcpController.cjs");
const { registerPrivacyHandlers } = require("./aiBridge/privacyHandlers.cjs");
const { getCliLauncherPath, TOOL_CLI_DISCOVERY_ENV_VAR } = require("../cli/discoveryPath.cjs");
const { injectCliTransportEnv } = require("../cli/cliChatSession.cjs");
const { getExternalMcpDiscoveryFilePath } = require("../cli/externalMcpDiscoveryPath.cjs");
@@ -997,6 +998,7 @@ function registerHandlers(ipcMain) {
registerAgentDiscoveryHandlers(context);
registerAgentProcessHandlers(context);
registerSdkStreamHandlers(context);
registerPrivacyHandlers(context);
if (externalMcpController) {
externalMcpController.registerHandlers(ipcMain, validateSenderOrSettings);
@@ -0,0 +1,28 @@
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { registerPrivacyHandlers } = require("./aiBridge/privacyHandlers.cjs");
const { createAiPrivacyService } = require("../shared/aiPrivacyService.cjs");
test("AI privacy IPC validates sender and shares semantic transforms", async () => {
const handlers = new Map();
const service = createAiPrivacyService();
service.configure({ enabled: true });
registerPrivacyHandlers({
ipcMain: { handle: (name, handler) => handlers.set(name, handler) },
validateSender: (event) => event.trusted === true,
aiPrivacyService: service,
});
const transform = handlers.get("netcatty:ai:privacy:transform");
assert.equal(transform({}, {}).ok, false);
const request = { chatSessionId: "catty-chat", operation: "redact", value: ["password=synthetic-secret", "192.0.2.1"] };
const masked = transform({ trusted: true }, request);
assert.equal(masked.ok, true);
assert.doesNotMatch(JSON.stringify(masked.value), /synthetic-secret|192\.0\.2\.1/);
assert.deepEqual(transform({ trusted: true }, { ...request, operation: "restore", value: masked.value }).value, request.value);
handlers.get("netcatty:ai:privacy:clear")({ trusted: true }, request.chatSessionId);
assert.equal(transform({ trusted: true }, { ...request, operation: "restore", value: masked.value }).ok, false);
service.invalidateConfiguration();
assert.equal(handlers.get("netcatty:ai:privacy:status")({ trusted: true }).ok, false);
});
@@ -6,6 +6,16 @@ const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
const { remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
const defaultAiPrivacyService = require("../../shared/aiPrivacyService.cjs");
function assertCattyPrivacySession(privacyService, chatSessionId) {
if (chatSessionId) {
// Unlike checkSession/redact, restore cannot create an unpinned session.
privacyService.restore(chatSessionId, []);
} else if (privacyService.isEnabled()) {
throw new Error("AI privacy requires a managed chat session.");
}
}
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
return mcpServerBridge.getSessionMeta?.(sessionId, chatSessionId) || {};
@@ -24,6 +34,7 @@ async function proxyCattyExecToWorker({
sessionId,
command,
chatSessionId,
beforeExecute,
}) {
if (!terminalWorkerManager?.request) {
return { ok: false, error: "Session not found" };
@@ -54,6 +65,7 @@ async function proxyCattyExecToWorker({
};
try {
beforeExecute();
return await terminalWorkerManager.request("netcatty:ai:exec", {
sessionId,
command,
@@ -63,6 +75,7 @@ async function proxyCattyExecToWorker({
commandBlocklist: mcpServerBridge.getCommandBlocklist?.(),
}, {
webContentsId: event?.sender?.id,
beforeExecute,
});
} catch (err) {
return { ok: false, error: err?.message || String(err) };
@@ -72,6 +85,7 @@ async function proxyCattyExecToWorker({
}
function registerCattyExecHandlers(ctx) {
const privacyService = ctx.aiPrivacyService || defaultAiPrivacyService;
with (ctx) {
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
// Validate IPC sender (Issue #17)
@@ -82,6 +96,12 @@ function registerCattyExecHandlers(ctx) {
if (mcpServerBridge.getPermissionMode() === "observer") {
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
}
const beforeExecute = () => assertCattyPrivacySession(privacyService, chatSessionId);
try {
beforeExecute();
} catch (err) {
return { ok: false, error: err?.message || "AI privacy protection is unavailable." };
}
const session = sessions?.get(sessionId);
if (!session) {
return proxyCattyExecToWorker({
@@ -91,6 +111,7 @@ function registerCattyExecHandlers(ctx) {
sessionId,
command,
chatSessionId,
beforeExecute,
});
}
@@ -120,6 +141,7 @@ function registerCattyExecHandlers(ctx) {
// (or immediately on a synchronous error/early return).
const withLockRelease = (factory) => {
try {
beforeExecute();
const result = factory();
return Promise.resolve(result).finally(releaseLock);
} catch (err) {
@@ -165,6 +187,7 @@ function registerCattyExecHandlers(ctx) {
chatSessionId,
});
if (!probed.ok) return probed;
beforeExecute();
const safety = mcpServerBridge.checkCommandSafetyForShell(
command,
mcpServerBridge.resolveSessionBlocklistShellKind(session),
@@ -226,6 +249,7 @@ function registerCattyExecHandlers(ctx) {
chatSessionId,
});
if (!probed.ok) return probed;
beforeExecute();
const safety = mcpServerBridge.checkCommandSafetyForShell(
command,
mcpServerBridge.resolveSessionBlocklistShellKind(session),
@@ -307,10 +331,16 @@ function registerCattyExecHandlers(ctx) {
if (!rpcMethod || typeof rpcMethod !== "string") {
return { ok: false, error: "rpcMethod is required" };
}
return mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
...(params || {}),
chatSessionId,
});
const beforeExecute = () => assertCattyPrivacySession(privacyService, chatSessionId);
try {
beforeExecute();
return await mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
...(params || {}),
chatSessionId,
}, { beforeExecute });
} catch (err) {
return { ok: false, error: err?.message || "AI privacy protection is unavailable." };
}
});
}
}
@@ -0,0 +1,154 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { registerCattyExecHandlers } = require("./cattyExecHandlers.cjs");
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
function harness(privacyService, overrides = {}) {
const handlers = new Map();
const effects = [];
const locks = [];
const mcpServerBridge = {
getPermissionMode: () => "auto",
getSessionBusyError: () => null,
getSessionMeta: () => ({ protocol: "ssh" }),
checkCommandSafetyCommonOnly: () => ({ blocked: false }),
checkCommandSafetyForShell: () => ({ blocked: false }),
resolveSessionBlocklistShellKind: () => "posix",
reserveSessionExecution: () => ({ ok: true, token: "lock" }),
releaseSessionExecution: () => { locks.push("released"); },
activePtyExecs: new Map(),
dispatchBuiltinRpc: async (method, params, context) => {
context.beforeExecute();
effects.push({ method, params });
return { ok: true };
},
...overrides.mcpServerBridge,
};
registerCattyExecHandlers({
ipcMain: { handle: (name, handler) => handlers.set(name, handler) },
validateSender: () => true,
aiPrivacyService: privacyService,
sessions: overrides.sessions || new Map(),
terminalWorkerManager: {
request: async (channel, payload) => {
effects.push({ channel, payload });
return { ok: true };
},
},
electronModule: {},
safeSend() {},
getFreshIdlePrompt: () => "",
execViaPty: async () => {
effects.push({ terminal: true });
return { ok: true };
},
...overrides,
mcpServerBridge,
});
const event = { sender: { id: 7 } };
return {
effects,
locks,
exec: (chatSessionId) => handlers.get("netcatty:ai:exec")(event, {
sessionId: "session", command: "echo synthetic", chatSessionId,
}),
capability: (chatSessionId) => handlers.get("netcatty:ai:capability")(event, {
rpcMethod: "netcatty/sftp/write", params: { sessionId: "session", path: "/synthetic" }, chatSessionId,
}),
};
}
test("Catty main execution requires a live pinned chat when privacy is enabled", async () => {
const service = createAiPrivacyService();
service.configure({ enabled: true });
const ctx = harness(service);
assert.equal((await ctx.exec("unseen")).ok, false);
assert.equal((await ctx.capability("unseen")).ok, false);
assert.equal(service.hasSession("unseen"), false);
assert.equal(ctx.effects.length, 0);
service.checkSession("pinned");
assert.equal((await ctx.exec("pinned")).ok, true);
assert.equal((await ctx.capability("pinned")).ok, true);
assert.equal(ctx.effects.length, 2);
service.configure({ enabled: false });
assert.equal((await ctx.exec("pinned")).ok, false);
assert.equal((await ctx.capability("pinned")).ok, false);
assert.equal(ctx.effects.length, 2);
});
test("Catty legacy no-chat execution is allowed only for initialized disabled privacy", async () => {
const loading = harness(createAiPrivacyService({ initialized: false }));
assert.equal((await loading.exec()).ok, false);
assert.equal((await loading.capability()).ok, false);
assert.equal(loading.effects.length, 0);
const service = createAiPrivacyService();
const ctx = harness(service);
assert.equal((await ctx.exec()).ok, true);
assert.equal((await ctx.capability()).ok, true);
service.configure({ enabled: true });
assert.equal((await ctx.exec()).ok, false);
assert.equal((await ctx.capability()).ok, false);
assert.equal(ctx.effects.length, 2);
});
for (const chatSessionId of ["pinned", undefined]) {
test(`Catty capability rechecks policy after dispatcher approval (${chatSessionId || "legacy"})`, async () => {
const service = createAiPrivacyService();
if (chatSessionId) {
service.configure({ enabled: true });
service.checkSession(chatSessionId);
}
const entered = deferred();
const approval = deferred();
let calls = 0;
const ctx = harness(service, {
mcpServerBridge: {
dispatchBuiltinRpc: async (_method, _params, context) => {
entered.resolve();
await approval.promise;
context.beforeExecute();
calls++;
return { ok: true };
},
},
});
const pending = ctx.capability(chatSessionId);
await entered.promise;
service.configure({ enabled: !chatSessionId });
approval.resolve();
assert.equal((await pending).ok, false);
assert.equal(calls, 0);
});
}
test("Catty terminal rechecks a deleted chat after shell probing and releases its lock", async () => {
const service = createAiPrivacyService();
service.configure({ enabled: true });
service.checkSession("pinned");
const entered = deferred();
const probe = deferred();
const ctx = harness(service, {
sessions: new Map([["session", { protocol: "local", shellKind: "posix", pty: { write() {} } }]]),
ensureSessionShellKindForExec: async () => {
entered.resolve();
await probe.promise;
return { ok: true };
},
});
const pending = ctx.exec("pinned");
await entered.promise;
service.clearSession("pinned");
const rejected = assert.rejects(pending, /privacy session is unavailable/);
probe.resolve();
await rejected;
assert.equal(ctx.effects.length, 0);
assert.deepEqual(ctx.locks, ["released"]);
});
@@ -2,6 +2,7 @@ const assert = require("node:assert/strict");
const test = require("node:test");
const { registerCattyExecHandlers } = require("./cattyExecHandlers.cjs");
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
function createFakeIpcMain() {
return {
@@ -14,6 +15,8 @@ function createFakeIpcMain() {
test("catty AI exec proxies to the terminal worker when the real session lives in the worker", async () => {
const ipcMain = createFakeIpcMain();
const aiPrivacyService = createAiPrivacyService();
aiPrivacyService.checkSession("chat-1");
const requests = [];
const terminalWorkerManager = {
request(channel, payload, options) {
@@ -55,6 +58,7 @@ test("catty AI exec proxies to the terminal worker when the real session lives i
registerCattyExecHandlers({
ipcMain,
aiPrivacyService,
validateSender: () => true,
sessions: new Map(),
terminalWorkerManager,
@@ -86,9 +90,10 @@ test("catty AI exec proxies to the terminal worker when the real session lives i
sessionMeta: { protocol: "ssh", deviceType: "", hostname: "host.example" },
commandBlocklist: [],
},
options: { webContentsId: 7 },
options: { webContentsId: 7, beforeExecute: requests[0].options.beforeExecute },
},
]);
assert.equal(typeof requests[0].options.beforeExecute, "function");
assert.deepEqual(locks, [
["reserve", "ssh-1", "exec"],
["release", "ssh-1", "token-1"],
@@ -206,6 +206,9 @@ class CodexAppServerConnection {
async request(method, params = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, options = {}) {
if (!options.skipStart) await this.start();
// Even an initialized connection yields in start(). Revalidate prompt
// handoffs after that await, before any JSONL payload reaches the process.
options.beforeSend?.();
const id = this.nextRequestId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
@@ -198,3 +198,26 @@ test("App Server process keys include executable and environment identity", () =
buildCodexAppServerKey("/a/codex", { HOME: "/b" }),
);
});
test("prompt guard runs after the connection's own startup await and prevents the JSONL write", async () => {
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
const privacy = createAiPrivacyService();
privacy.checkSession("chat");
const child = createFakeChild();
const writes = [];
child.stdin.on("data", (chunk) => writes.push(String(chunk)));
const connection = new CodexAppServerConnection({ binPath: "/bin/codex", spawnImpl: () => child });
let release;
connection.process = child;
connection.start = () => new Promise((resolve) => { release = resolve; });
const run = connection.request("turn/start", { input: [{ type: "text", text: "raw-secret" }] }, undefined, {
beforeSend: () => privacy.checkSession("chat"),
});
const rejected = assert.rejects(run, /start a new chat/);
privacy.configure({ enabled: true });
release();
await rejected;
assert.deepEqual(writes, []);
assert.equal(connection.pending.size, 0);
connection.close();
});
@@ -180,6 +180,7 @@ class CodexAppServerRuntime {
connectionFactory,
sendInteractionRequest,
sendInteractionCleared,
transformUserInputAnswers,
interruptRequestTimeoutMs = INTERRUPT_REQUEST_TIMEOUT_MS,
interruptGraceMs = INTERRUPT_GRACE_MS,
} = {}) {
@@ -187,6 +188,7 @@ class CodexAppServerRuntime {
this.connectionFactory = connectionFactory;
this.sendInteractionRequest = sendInteractionRequest;
this.sendInteractionCleared = sendInteractionCleared;
this.transformUserInputAnswers = transformUserInputAnswers;
this.interruptRequestTimeoutMs = interruptRequestTimeoutMs;
this.interruptGraceMs = interruptGraceMs;
this.connections = new Map();
@@ -257,6 +259,7 @@ class CodexAppServerRuntime {
emitter,
signal,
sender,
assertSessionValid,
}) {
const throwIfAborted = () => {
if (!signal?.aborted) return;
@@ -279,9 +282,11 @@ class CodexAppServerRuntime {
config: buildThreadConfig(injectedMcpServers),
};
assertSessionValid?.();
const requestOptions = { beforeSend: assertSessionValid };
const threadResult = resumeThreadId
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams })
: await connection.request("thread/start", threadParams);
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams }, undefined, requestOptions)
: await connection.request("thread/start", threadParams, undefined, requestOptions);
throwIfAborted();
const threadId = threadResult?.thread?.id || resumeThreadId;
if (!threadId) throw new Error("Codex App Server did not return a thread id");
@@ -297,6 +302,7 @@ class CodexAppServerRuntime {
emitter,
signal,
sender,
assertSessionValid,
lastError: null,
settled: false,
cancelRequested: false,
@@ -325,6 +331,7 @@ class CodexAppServerRuntime {
}
try {
assertSessionValid?.();
const turnResult = await connection.request("turn/start", {
threadId,
input: buildTurnInput(prompt, attachments),
@@ -335,7 +342,7 @@ class CodexAppServerRuntime {
model: selection.model || null,
effort: selection.effort || null,
summary: "concise",
});
}, undefined, requestOptions);
const turnId = turnResult?.turn?.id;
if (turnId) this.#assignTurnId(context, turnId);
await completion;
@@ -388,12 +395,13 @@ class CodexAppServerRuntime {
const steerPromise = (async () => {
try {
context.assertSessionValid?.();
const response = await context.connection.request("turn/steer", {
threadId: context.threadId,
expectedTurnId: context.turnId,
input: buildTurnInput(prompt, attachments),
clientUserMessageId: clientUserMessageId || null,
});
}, undefined, { beforeSend: context.assertSessionValid });
if (context.cancelRequested || context.signal?.aborted || context.settled) {
return { status: "cancelled" };
}
@@ -861,7 +869,13 @@ class CodexAppServerRuntime {
respondInteraction(interactionId, response, sender) {
const pending = this.pendingInteractions.get(interactionId);
if (sender && pending?.context?.sender && pending.context.sender !== sender) return false;
return this.#resolveInteraction(interactionId, response);
const safeResponse = pending?.kind === "user-input" && this.transformUserInputAnswers
? {
...response,
answers: this.transformUserInputAnswers(response?.answers || {}, pending.context),
}
: response;
return this.#resolveInteraction(interactionId, safeResponse);
}
/**
@@ -311,6 +311,56 @@ test("runtime routes native approvals and request_user_input responses", async (
await run;
});
test("runtime transforms user answers with authoritative pending chat context after sender validation", () => {
const responses = [];
const sender = {};
const transformedContexts = [];
const runtime = new CodexAppServerRuntime({
transformUserInputAnswers(answers, context) {
transformedContexts.push(context.chatSessionId);
return { ...answers, question: { answers: ["protected-answer"] } };
},
});
runtime.pendingInteractions.set("private-answer", {
interactionId: "private-answer",
kind: "user-input",
context: { chatSessionId: "chat-private", sender },
connection: { respond: (id, result) => responses.push({ id, result }) },
rpcId: 90,
params: {},
});
assert.equal(runtime.respondInteraction("private-answer", {
answers: { question: { answers: ["private-input"] } },
}, {}), false);
assert.equal(transformedContexts.length, 0);
assert.equal(runtime.respondInteraction("private-answer", {
answers: { question: { answers: ["private-input"] } },
}, sender), true);
assert.deepEqual(transformedContexts, ["chat-private"]);
assert.deepEqual(responses, [{ id: 90, result: { answers: { question: { answers: ["protected-answer"] } } } }]);
});
test("failed user answer transformation sends no raw reply and leaves interaction pending", () => {
const responses = [];
const runtime = new CodexAppServerRuntime({
transformUserInputAnswers() { throw new Error("Privacy settings changed"); },
});
runtime.pendingInteractions.set("private-answer", {
interactionId: "private-answer",
kind: "user-input",
context: { chatSessionId: "chat-private" },
connection: { respond: (id, result) => responses.push({ id, result }) },
rpcId: 91,
params: {},
});
assert.throws(() => runtime.respondInteraction("private-answer", {
answers: { question: { answers: ["private-input"] } },
}), /Privacy settings changed/);
assert.deepEqual(responses, []);
assert.equal(runtime.pendingInteractions.has("private-answer"), true);
runtime.close();
});
test("cancelInteractionTimeout re-arms the absolute approval deadline (Catty/MCP style)", async () => {
let connection;
let interaction;
@@ -848,3 +898,45 @@ test("model and file-change normalization preserve UI contract", async () => {
assert.deepEqual(catalog.models[1].thinkingLevels, ["low", "high"]);
assert.equal(catalog.models[1].defaultThinkingLevel, "high");
});
for (const startupStage of ["connection", "thread"]) {
test(`privacy off-to-on while Codex ${startupStage} startup is paused prevents turn/start`, async () => {
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
const privacy = createAiPrivacyService();
privacy.checkSession("chat-privacy-race");
let notifyReady;
let release;
const ready = new Promise((resolve) => { notifyReady = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => {
connection = new FakeConnection(options);
if (startupStage === "connection") {
connection.start = async () => { notifyReady(); await gate; };
} else {
const request = connection.request.bind(connection);
connection.request = async (method, params) => {
if (method === "thread/start") { notifyReady(); await gate; }
return request(method, params);
};
}
return connection;
},
});
const run = runtime.runTurn({
requestId: "privacy-race", chatSessionId: "chat-privacy-race",
prompt: "raw-secret", binPath: "/bin/codex", emitter: createEmitter(),
assertSessionValid: () => privacy.checkSession("chat-privacy-race"),
});
const rejected = assert.rejects(run, /start a new chat/);
await ready;
privacy.configure({ enabled: true });
release();
await rejected;
assert.equal(connection.requests.some(({ method }) => method === "turn/start"), false);
assert.equal(runtime.activeByRequest.size, 0);
assert.equal(runtime.activeByThread.size, 0);
await runtime.cleanupChatSession("chat-privacy-race");
});
}
@@ -0,0 +1,35 @@
"use strict";
const privacyService = require("../../shared/aiPrivacyService.cjs");
function registerPrivacyHandlers({ ipcMain, validateSender, aiPrivacyService = privacyService }) {
ipcMain.handle("netcatty:ai:privacy:status", (event) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized" };
try {
return { ok: true, enabled: aiPrivacyService.isEnabled() };
} catch (error) {
return { ok: false, error: error.message };
}
});
ipcMain.handle("netcatty:ai:privacy:transform", (event, request = {}) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized" };
try {
if (request.operation !== "redact" && request.operation !== "restore") {
throw new Error("Invalid AI privacy operation.");
}
return {
ok: true,
value: aiPrivacyService[request.operation](request.chatSessionId, request.value),
};
} catch (error) {
return { ok: false, error: error.message };
}
});
ipcMain.handle("netcatty:ai:privacy:clear", (event, chatSessionId) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized" };
aiPrivacyService.clearSession(chatSessionId);
return { ok: true };
});
}
module.exports = { registerPrivacyHandlers };
@@ -233,7 +233,7 @@ function buildClaudePromptInput(prompt, attachments) {
* @param {object} args.emitter createStreamEmitter(...)
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
*/
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn }) {
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
ensureClaudeConfig();
let query = queryFn;
if (!query) {
@@ -246,6 +246,7 @@ async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn })
let sessionId = null;
let hasContent = false;
try {
assertSessionValid?.();
const stream = query({ prompt: promptInput, options });
for await (const message of stream) {
if (options.abortController?.signal?.aborted) break;
@@ -557,7 +557,7 @@ function buildCodebuddyPromptInput(prompt, attachments) {
* @param {object} args.emitter createStreamEmitter(...)
* @param {Function} [args.queryFn] inject @tencent-ai/agent-sdk query (for tests)
*/
async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn }) {
async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
if (options.abortController?.signal?.aborted) {
emitter.emitDone();
return { sessionId: null };
@@ -585,6 +585,7 @@ async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn
let queryRef = null;
let removeAbortListener = null;
try {
assertSessionValid?.();
queryRef = query({ prompt: promptInput, options });
const signal = options.abortController?.signal;
const interruptQuery = () => {
@@ -119,7 +119,7 @@ class CodebuddySessionManager {
* @param {string} [args.resumeSessionId] resume an existing session by ID
* @returns {Promise<object|null>} session instance or null if V2 unavailable
*/
async getOrCreateSession({ sessionKey, sessionOptions, resumeSessionId }) {
async getOrCreateSession({ sessionKey, sessionOptions, resumeSessionId, assertSessionValid }) {
const fingerprint = computeOptionsFingerprint(sessionOptions);
const existing = this.sessions.get(sessionKey);
if (existing) {
@@ -150,6 +150,8 @@ class CodebuddySessionManager {
const resumeSession = sdk.unstable_v2_resumeSession;
if (!createSession || !resumeSession) return null;
// Session options can themselves contain system/custom-agent prompts.
assertSessionValid?.();
let session;
try {
const callbackState = createSessionCallbackState(sessionOptions);
@@ -180,7 +182,7 @@ class CodebuddySessionManager {
*/
async runTurn({
sessionKey, prompt, attachments, options, emitter,
sessionOptions, resumeSessionId,
sessionOptions, resumeSessionId, assertSessionValid,
}) {
const signal = options.abortController?.signal;
if (signal?.aborted) {
@@ -189,7 +191,7 @@ class CodebuddySessionManager {
}
const session = await this.getOrCreateSession({
sessionKey, sessionOptions, resumeSessionId,
sessionKey, sessionOptions, resumeSessionId, assertSessionValid,
});
if (!session) {
if (signal?.aborted) {
@@ -230,6 +232,7 @@ class CodebuddySessionManager {
}
}
assertSessionValid?.();
try {
// Send before the initial connection so resumed sessions suppress
// historical replay and stream only the response to this prompt.
@@ -238,6 +241,7 @@ class CodebuddySessionManager {
} else {
// Async iterable of UserMessage — send first message.
for await (const msg of promptInput) {
assertSessionValid?.();
await session.send(msg);
}
}
@@ -245,6 +249,8 @@ class CodebuddySessionManager {
// Initial transport setup happens inside send(). Release any acquired
// session lock/process before the caller falls back to legacy query().
this.closeSession(sessionKey);
// Do not reinterpret invalid privacy state as a transport fallback.
assertSessionValid?.();
if (signal?.aborted) {
emitter.emitDone();
return { sessionId, usedV2: true };
@@ -345,7 +345,7 @@ function translateCodexEvent(event, emitter, state) {
* @param {Function} [args.CodexCtor] inject Codex class (for tests)
*/
async function runCodexTurn({
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor,
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor, assertSessionValid,
}) {
const Codex = CodexCtor || (await import("@openai/codex-sdk")).Codex;
const promptInput = buildCodexPromptInput(prompt, attachments);
@@ -357,9 +357,12 @@ async function runCodexTurn({
? codex.resumeThread(resumeThreadId, threadOptions)
: codex.startThread(threadOptions);
assertSessionValid?.();
const { events } = await thread.runStreamed(promptInput, signal ? { signal } : undefined);
let hasContent = false;
const state = { reasoningOpen: false };
// runStreamed returns a lazy iterator; its first read starts execution.
assertSessionValid?.();
for await (const event of events) {
// Capture + emit the resumable thread id as EARLY as possible — it exists
// the moment `thread.started` arrives (the first event). Emitting it only at
@@ -477,6 +477,7 @@ async function runCopilotTurn({
emitter,
signal,
sdkModule,
assertSessionValid,
}) {
let resolvedModule = sdkModule;
if (!resolvedModule) {
@@ -564,6 +565,7 @@ async function runCopilotTurn({
}
let final;
try {
assertSessionValid?.();
final = await session.sendAndWait(buildCopilotMessageOptions({ prompt, attachments }));
} finally {
try { unsubscribe(); } catch { /* best effort */ }
@@ -477,6 +477,7 @@ async function runCursorCliTurn({
workspaceCwd,
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
forceKillImpl,
assertSessionValid,
}) {
const cliPath = String(binPath || "").trim();
if (!cliPath) {
@@ -539,6 +540,7 @@ async function runCursorCliTurn({
};
try {
assertSessionValid?.();
child = spawnCursorCliProcess(spawnImpl, cliPath, args, {
cwd: effectiveCwd,
env: childEnv,
@@ -320,7 +320,7 @@ function runCursorTurn(options) {
}
async function runCursorTurnInProcess({
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule,
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule, assertSessionValid, getPrompt,
}) {
let resolvedModule = sdkModule;
if (!resolvedModule) {
@@ -370,10 +370,13 @@ async function runCursorTurnInProcess({
if (sessionId) emitter.sessionId(sessionId);
if (signal?.aborted) return { sessionId };
const sendMessage = buildCursorSendMessage(prompt, attachments);
run = await abortable(withExclusiveProcessEnv(runtimeEnv, () => {
run = await abortable(withExclusiveProcessEnv(runtimeEnv, async () => {
// The utility worker receives prompt content only after startup and the
// main process has revalidated this chat at the handoff boundary.
const input = getPrompt ? await getPrompt() : { prompt, attachments };
if (signal?.aborted) throw new CursorTurnAbortError();
return agent.send(sendMessage);
assertSessionValid?.();
return agent.send(buildCursorSendMessage(input.prompt, input.attachments));
}), signal, (lateRun) => {
if (lateRun && typeof lateRun.cancel === "function") {
void lateRun.cancel().catch(() => {});
@@ -12,11 +12,33 @@ for (const method of [
}
let started = false;
let pendingPrompt = null;
function getPrompt() {
return new Promise((resolve, reject) => {
if (controller.signal.aborted) {
reject(new Error("Cursor turn was interrupted"));
return;
}
pendingPrompt = { resolve, reject };
process.parentPort.postMessage({ type: "prompt-request" });
});
}
process.parentPort.on("message", ({ data }) => {
if (data?.type === "abort") controller.abort();
if (data?.type === "abort") {
controller.abort();
pendingPrompt?.reject(new Error("Cursor turn was interrupted"));
pendingPrompt = null;
}
if (data?.type === "prompt") {
pendingPrompt?.resolve({ prompt: data.prompt, attachments: data.attachments });
pendingPrompt = null;
}
if (data?.type === "start" && !started) {
started = true;
void runCursorTurnInProcess({ ...data.params, emitter, signal: controller.signal }).then(
void runCursorTurnInProcess({
...data.params, emitter, signal: controller.signal,
getPrompt: data.params.deferPrompt ? getPrompt : undefined,
}).then(
(result) => process.parentPort.postMessage({ type: "result", result }),
(error) => process.parentPort.postMessage({ type: "error", message: error?.message || String(error) }),
);
@@ -12,7 +12,11 @@ function createCursorWorker(options) {
// Each turn gets its own environment and SDK instance. A stalled SDK network
// request cannot retain a main-process environment lock or block another chat.
function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = createCursorWorker) {
function runCursorWorkerTurn({ emitter, signal, assertSessionValid, ...params }, createWorker = createCursorWorker) {
// Functions cannot cross the utility-process boundary. Keep semantic content
// in main until the worker is ready to send, then validate before releasing it.
const { prompt, attachments, ...startupParams } = params;
const deferPrompt = typeof assertSessionValid === "function";
let sessionId = params.resumeSessionId || null;
if (signal?.aborted) return Promise.resolve({ sessionId });
return new Promise((resolve) => {
@@ -30,6 +34,11 @@ function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = crea
clearTimeout(stopTimer);
worker.kill();
};
const privacyFailure = (error) => {
emitter.emitError(error?.message || String(error));
finish({ sessionId });
terminate();
};
const onAbort = () => {
if (spawned) worker.postMessage({ type: "abort" });
// Allow run.cancel()/agent.close() first, then contain a stuck startup.
@@ -50,10 +59,25 @@ function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = crea
worker.once("spawn", () => {
spawned = true;
if (settled) terminate();
else worker.postMessage({ type: "start", params });
else {
try {
assertSessionValid?.();
worker.postMessage({ type: "start", params: deferPrompt ? { ...startupParams, deferPrompt } : params });
} catch (error) {
privacyFailure(error);
}
}
});
worker.on("message", (message) => {
if (message?.type === "event") {
if (message?.type === "prompt-request") {
if (settled || !deferPrompt) return;
try {
assertSessionValid();
worker.postMessage({ type: "prompt", prompt, attachments });
} catch (error) {
privacyFailure(error);
}
} else if (message?.type === "event") {
if (settled) return;
if (message.method === "sessionId") sessionId = message.args[0];
const handler = emitter[message.method];
@@ -111,3 +111,52 @@ test("utility-process fatal errors retain their reason and settle only once", as
assert.deepEqual(errors, ["FatalError: v8::Heap"]);
assert.equal(kills, 1);
});
test("Cursor worker startup withholds semantic content until main validates the prompt handoff", async () => {
const { EventEmitter } = require("node:events");
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
const privacy = createAiPrivacyService();
privacy.checkSession("chat");
const child = new EventEmitter();
const sent = [];
const errors = [];
let killed = false;
child.postMessage = (message) => sent.push(structuredClone(message));
child.kill = () => { killed = true; child.emit("exit", 0); };
const turn = runCursorWorkerTurn({
prompt: "raw-secret", attachments: [{ data: "raw-image" }],
emitter: { emitError: (error) => errors.push(error) },
assertSessionValid: () => privacy.checkSession("chat"),
}, () => child);
child.emit("spawn");
assert.equal(sent[0].type, "start");
assert.equal(sent[0].params.deferPrompt, true);
assert.equal("prompt" in sent[0].params, false);
assert.equal("attachments" in sent[0].params, false);
assert.equal("assertSessionValid" in sent[0].params, false);
privacy.configure({ enabled: true });
child.emit("message", { type: "prompt-request" });
await turn;
assert.equal(sent.length, 1);
assert.equal(killed, true);
assert.match(errors.join("\n"), /start a new chat/);
});
test("Cursor releases the original prompt only on a valid post-startup request", async () => {
const { EventEmitter } = require("node:events");
const child = new EventEmitter();
const sent = [];
let checks = 0;
child.postMessage = (message) => sent.push(structuredClone(message));
child.kill = () => child.emit("exit", 0);
const turn = runCursorWorkerTurn({
prompt: "hello", attachments: [], emitter: {},
assertSessionValid: () => { checks++; },
}, () => child);
child.emit("spawn");
child.emit("message", { type: "prompt-request" });
assert.equal(checks, 2);
assert.deepEqual(sent[1], { type: "prompt", prompt: "hello", attachments: [] });
child.emit("message", { type: "result", result: { sessionId: null } });
await turn;
});
+85 -3
View File
@@ -19,17 +19,99 @@
* { type: 'session-id', sessionId }
* { type: 'error', error }
*/
function createStreamEmitter({ safeSend, sender, requestId }) {
const emitEvent = (event) => {
function createStreamEmitter({ safeSend, sender, requestId, restoreValue }) {
const pending = { "text-delta": "", "reasoning-delta": "" };
const restore = typeof restoreValue === "function" ? restoreValue : (value) => value;
const streamFields = { "text-delta": "textDelta", "reasoning-delta": "delta" };
const displayFields = {
"tool-call": ["args", "input"],
"tool-result": ["output", "result"],
"file-change": ["changes"],
"web-search": ["query"],
"plan-update": ["items"],
warning: ["message"],
status: ["message"],
error: ["error"],
hook: ["toolInput", "error", "reason", "message", "title", "compactSummary"],
};
const restoreQuestionSchema = (schema) => {
if (Array.isArray(schema)) return schema.map(restoreQuestionSchema);
if (!schema || typeof schema !== "object") return schema;
return Object.fromEntries(Object.entries(schema).map(([key, value]) => [
key,
["title", "description", "enum", "enumNames"].includes(key)
? restore(value)
: restoreQuestionSchema(value),
]));
};
const sendEvent = (event) => {
safeSend(sender, "netcatty:ai:sdk-agent:event", { requestId, event });
};
// Providers may split a pseudonym anywhere, including between "[" and "N".
// Only the unfinished suffix is retained; ordinary text streams immediately.
const restoreDelta = (type, delta, final = false) => {
const text = pending[type] + String(delta || "");
let end = text.length;
if (!final) {
const start = text.lastIndexOf("[");
if (start >= 0) {
const suffix = text.slice(start);
if ("[NC_".startsWith(suffix)
|| (suffix.startsWith("[NC_") && !suffix.includes("]"))) {
if (suffix.length > 128) throw new Error("AI privacy could not restore an incomplete response token.");
end = start;
}
}
}
pending[type] = text.slice(end);
return end ? String(restore(text.slice(0, end))) : "";
};
const flush = (type) => {
if (!pending[type]) return;
const text = restoreDelta(type, "", true);
if (text) sendEvent({ type, [streamFields[type]]: text });
};
const emitEvent = (event) => {
if (!restoreValue) return sendEvent(event);
const field = streamFields[event.type];
if (field) {
const text = restoreDelta(event.type, event[field]);
if (text) sendEvent({ ...event, [field]: text });
return;
}
if (event.type === "reasoning-end") flush("reasoning-delta");
const output = { ...event };
for (const key of displayFields[event.type] || []) {
if (output[key] !== undefined) output[key] = restore(output[key]);
}
if (event.type === "elicitation-create" && event.request) {
output.request = { ...event.request };
if (event.request.message !== undefined) output.request.message = restore(event.request.message);
if (event.request.requestedSchema) {
output.request.requestedSchema = restoreQuestionSchema(event.request.requestedSchema);
}
}
sendEvent(output);
};
return {
emitEvent,
emitDone() {
if (restoreValue) {
flush("text-delta");
flush("reasoning-delta");
}
safeSend(sender, "netcatty:ai:sdk-agent:done", { requestId });
},
emitError(error) {
safeSend(sender, "netcatty:ai:sdk-agent:error", { requestId, error });
// Errors can follow an aborted partial token. Never emit that suffix, and
// never let a failed restoration recursively prevent error delivery.
pending["text-delta"] = "";
pending["reasoning-delta"] = "";
let restored;
try { restored = restore(error); } catch {
restored = "AI privacy could not restore the response. Start a new conversation.";
}
safeSend(sender, "netcatty:ai:sdk-agent:error", { requestId, error: restored });
},
text(textDelta) {
if (textDelta) emitEvent({ type: "text-delta", textDelta });
@@ -58,3 +58,92 @@ test("convenience helpers emit the canonical event shapes", () => {
assert.equal(calls[7].payload.event.totalTokens, 15);
assert.equal(calls[9].payload.event.sessionId, "sess-9");
});
const PRIVATE_TOKEN = "[NC_aabbccddeeff_SECRET_1]";
function restoreTestValue(value) {
if (typeof value === "string") {
return value.replace(/\[NC_[^\]]*(?:\]|$)/g, (token) => {
if (token !== PRIVATE_TOKEN) throw new Error("Unknown privacy token");
return "private-original";
});
}
if (Array.isArray(value)) return value.map(restoreTestValue);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, restoreTestValue(entry)]));
}
return value;
}
test("privacy restoration buffers tokens at every text and reasoning split boundary", () => {
for (const stream of ["text", "reasoning"]) {
for (let split = 1; split < PRIVATE_TOKEN.length; split += 1) {
const { calls, safeSend } = recordingSend();
const emitter = createStreamEmitter({
safeSend, sender: {}, requestId: "private", restoreValue: restoreTestValue,
});
emitter[stream](`Before ${PRIVATE_TOKEN.slice(0, split)}`);
emitter[stream](`${PRIVATE_TOKEN.slice(split)} after`);
if (stream === "reasoning") emitter.reasoningEnd();
emitter.emitDone();
const text = calls.map((entry) => entry.payload.event?.textDelta || entry.payload.event?.delta || "").join("");
assert.equal(text, "Before private-original after", `${stream} split ${split}`);
assert.doesNotMatch(JSON.stringify(calls), /\[NC_/);
}
}
});
test("privacy restoration supports character-at-a-time tokens and preserves event routing identifiers", () => {
const { calls, safeSend } = recordingSend();
const emitter = createStreamEmitter({
safeSend, sender: {}, requestId: PRIVATE_TOKEN, restoreValue: restoreTestValue,
});
for (const character of PRIVATE_TOKEN) emitter.emitEvent({ type: "text-delta", textDelta: character });
emitter.toolCall(PRIVATE_TOKEN, { command: `echo ${PRIVATE_TOKEN}` }, PRIVATE_TOKEN);
emitter.sessionId(PRIVATE_TOKEN);
emitter.emitDone();
const events = calls.filter((entry) => entry.payload.event).map((entry) => entry.payload.event);
assert.equal(events[0].textDelta, "private-original");
assert.equal(events[1].toolName, PRIVATE_TOKEN);
assert.equal(events[1].toolCallId, PRIVATE_TOKEN);
assert.deepEqual(events[1].args, { command: "echo private-original" });
assert.equal(events[2].sessionId, PRIVATE_TOKEN);
assert.ok(calls.every((entry) => entry.payload.requestId === PRIVATE_TOKEN));
});
test("privacy restoration restores activity fields and never emits an unfinished token on abort", () => {
const { calls, safeSend } = recordingSend();
const emitter = createStreamEmitter({
safeSend, sender: {}, requestId: "private", restoreValue: restoreTestValue,
});
emitter.toolResult("tool-1", { output: PRIVATE_TOKEN }, "tool");
emitter.fileChange("file-1", [{ path: PRIVATE_TOKEN }], "completed");
emitter.planUpdate("plan-1", [{ text: PRIVATE_TOKEN }], "running");
emitter.webSearch("search-1", PRIVATE_TOKEN, "completed");
emitter.warning("warning-1", PRIVATE_TOKEN);
emitter.text("Before [NC_aabb");
emitter.emitError("Aborted");
assert.doesNotMatch(JSON.stringify(calls), /\[NC_/);
assert.equal(calls.at(-1).payload.error, "Aborted");
assert.match(JSON.stringify(calls), /private-original/);
});
test("privacy restoration fails closed on unknown complete tokens and incomplete final tokens", () => {
const { calls, safeSend } = recordingSend();
const emitter = createStreamEmitter({
safeSend, sender: {}, requestId: "private", restoreValue: restoreTestValue,
});
assert.throws(() => emitter.text("[NC_other_SECRET_1]"), /Unknown privacy token/);
emitter.text("[NC_incomplete");
assert.throws(() => emitter.emitDone(), /Unknown privacy token/);
emitter.emitError("[NC_other_SECRET_1]");
assert.equal(calls.length, 1);
assert.match(calls[0].payload.error, /Start a new conversation/);
assert.doesNotMatch(JSON.stringify(calls), /\[NC_/);
});
test("disabled emitter does not buffer token-looking literal text", () => {
const { calls, safeSend } = recordingSend();
const emitter = createStreamEmitter({ safeSend, sender: {}, requestId: "plain" });
emitter.text("[NC_");
assert.equal(calls[0].payload.event.textDelta, "[NC_");
});
@@ -548,10 +548,13 @@ async function establishGrokAcpSession(rpc, {
toolIntegrationMode,
systemContext,
agentCapabilities,
assertSessionValid,
} = {}) {
const methods = planGrokAcpSessionEstablish({ resumeSessionId, agentCapabilities });
let lastError = null;
for (const method of methods) {
// session/new carries system context; retries must recheck as well.
assertSessionValid?.();
try {
if (method === "new") {
const created = await rpc.request(
@@ -929,6 +932,7 @@ async function runGrokAcpTurn({
forceKillImpl,
// inject for tests: skip real spawn
rpcClientFactory,
assertSessionValid,
}) {
const cliPath = String(binPath || "").trim();
if (!cliPath) {
@@ -975,6 +979,7 @@ async function runGrokAcpTurn({
permissionMode,
toolIntegrationMode,
systemContext: systemPrompt,
assertSessionValid,
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
});
state.sessionId = established.sessionId;
@@ -987,6 +992,7 @@ async function runGrokAcpTurn({
});
// Only accept updates for this turn's prompt (not session/load replay).
state.acceptUpdates = true;
assertSessionValid?.();
const promptResult = await client.request(
"session/prompt",
buildGrokAcpPromptParams(state.sessionId, effectivePrompt),
@@ -1207,6 +1213,7 @@ async function runGrokAcpTurn({
permissionMode,
toolIntegrationMode,
systemContext: systemPrompt,
assertSessionValid,
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
});
state.sessionId = established.sessionId;
@@ -1224,6 +1231,7 @@ async function runGrokAcpTurn({
// Await the prompt RPC only (no race with promptDone). Process death
// rejects via rejectAll on close; success sets turnCompleted + usage in
// handleGrokAcpMessage before resolve.
assertSessionValid?.();
await rpc.request(
"session/prompt",
buildGrokAcpPromptParams(state.sessionId, effectivePrompt),
@@ -736,6 +736,7 @@ async function runGrokTurn({
mergeMcp,
abortGraceMs = GROK_ABORT_GRACE_MS,
forceKillImpl,
assertSessionValid,
}) {
const cliPath = String(binPath || "").trim();
if (!cliPath) {
@@ -789,6 +790,7 @@ async function runGrokTurn({
};
try {
assertSessionValid?.();
child = spawnGrokProcess(spawnImpl, cliPath, args, {
cwd: effectiveCwd,
env: childEnv,
+12 -1
View File
@@ -45,7 +45,7 @@ const DRIVER_REGISTRY = {
resume: ctx.resumeSessionId,
toolIntegrationMode: ctx.toolIntegrationMode,
});
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter });
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter, assertSessionValid: ctx.assertSessionValid });
},
async listModels(ctx) {
return claude.listClaudeModels({
@@ -68,6 +68,7 @@ const DRIVER_REGISTRY = {
const threadOptions = codex.buildCodexThreadOptions({ cwd: ctx.cwd, model: ctx.model });
return codex.runCodexTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
constructorOptions,
threadOptions,
@@ -91,6 +92,7 @@ const DRIVER_REGISTRY = {
});
return copilot.runCopilotTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
clientOptions,
sessionOptions,
@@ -116,6 +118,7 @@ const DRIVER_REGISTRY = {
if (authMode === "cli-login") {
return cursorCli.runCursorCliTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
binPath: ctx.cursorCliBinPath || ctx.binPath,
cwd: ctx.cwd,
chatSessionId: ctx.chatSessionId,
@@ -138,6 +141,7 @@ const DRIVER_REGISTRY = {
});
return cursor.runCursorTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
agentOptions,
runtimeEnv: ctx.env,
@@ -251,6 +255,7 @@ const DRIVER_REGISTRY = {
const v2Result = await codebuddySessionManager.runTurn({
sessionKey,
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
options,
emitter: ctx.emitter,
@@ -267,6 +272,7 @@ const DRIVER_REGISTRY = {
// Fallback: legacy query() per-turn (supports all Options fields).
return codebuddy.runCodebuddyTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
options,
emitter: ctx.emitter,
@@ -282,6 +288,7 @@ const DRIVER_REGISTRY = {
return codebuddySessionManager.steer({
sessionKey,
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
});
},
@@ -298,6 +305,7 @@ const DRIVER_REGISTRY = {
async runTurn(ctx) {
return opencode.runOpenCodeTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
systemPrompt: ctx.systemPrompt,
attachments: ctx.attachments,
cwd: ctx.cwd,
@@ -337,6 +345,7 @@ const DRIVER_REGISTRY = {
// is handled on ACP (default runtime) via historySeed + session/new.
return grok.runGrokTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
binPath: ctx.binPath,
cwd: ctx.cwd,
model: ctx.model,
@@ -351,6 +360,7 @@ const DRIVER_REGISTRY = {
}
return grokAcp.runGrokAcpTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
systemPrompt: ctx.systemPrompt,
binPath: ctx.binPath,
cwd: ctx.cwd,
@@ -431,6 +441,7 @@ const DRIVER_REGISTRY = {
async runTurn(ctx) {
return mimo.runMimoTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
systemPrompt: ctx.systemPrompt,
attachments: ctx.attachments,
cwd: ctx.cwd,
@@ -113,3 +113,32 @@ test("Copilot registry catalog uses the configured profile environment", async (
assert.deepEqual(models, [{ id: "profile-model", name: "profile-model" }]);
assert.deepEqual(receivedEnv, env);
});
test("registry forwards the same live session guard to every prompt driver", async (t) => {
const assertSessionValid = () => {};
const cases = [
["claude", "./claudeDriver.cjs", "runClaudeTurn", {}],
["codex", "./codexDriver.cjs", "runCodexTurn", {}],
["copilot", "./copilotDriver.cjs", "runCopilotTurn", {}],
["cursor", "./cursorDriver.cjs", "runCursorTurn", {}],
["cursor", "./cursorCliDriver.cjs", "runCursorCliTurn", { cursorAuthMode: "cli-login" }],
["codebuddy", "./codebuddyDriver.cjs", "runCodebuddyTurn", { maxBudgetUsd: 1 }],
["opencode", "./opencodeDriver.cjs", "runOpenCodeTurn", {}],
["grok", "./grokAcpDriver.cjs", "runGrokAcpTurn", {}],
["grok", "./grokDriver.cjs", "runGrokTurn", { grokRuntime: "streaming-json" }],
["mimo", "./mimoDriver.cjs", "runMimoTurn", {}],
];
for (const [backend, modulePath, method, extra] of cases) {
const driver = require(modulePath);
let received;
t.mock.method(driver, method, async (context) => { received = context; return {}; });
await getDriver(backend).runTurn({
prompt: "hello", emitter: {}, env: {}, assertSessionValid, ...extra,
});
assert.equal(received.assertSessionValid, assertSessionValid, method);
}
let received;
t.mock.method(codebuddySessionManager, "runTurn", async (context) => { received = context; return {}; });
await getDriver("codebuddy").runTurn({ prompt: "hello", emitter: {}, env: {}, assertSessionValid });
assert.equal(received.assertSessionValid, assertSessionValid, "CodeBuddy V2");
});
+3 -1
View File
@@ -488,7 +488,7 @@ function createMiMoEmitter(emitter) {
async function runMimoTurn({
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
skillsPathAllowlist, permissionMode = "confirm", chatSessionId, requestApprovalFromRenderer, clearPendingApprovals,
resumeSessionId, env, binPath, emitter, abortController, mimoFactory, mimoConfigReader,
resumeSessionId, env, binPath, emitter, abortController, mimoFactory, mimoConfigReader, assertSessionValid,
}) {
const emit = createMiMoEmitter(emitter);
const nativeSkillOptions = { mimo: true, env: { ...process.env, ...env }, cwd };
@@ -578,6 +578,8 @@ async function runMimoTurn({
if (!sessionId) throw new Error("MiMo Code did not create a session");
emit.sessionId(sessionId);
// Keep the guard before the event loop and prompt send (no awaits between).
assertSessionValid?.();
const stopEventLoopWait = createStopWait();
const serverExitWait = createMimoExitWait(instance.server);
const answeredPermissions = new Set();
@@ -554,7 +554,7 @@ function createStopWait() {
async function runOpenCodeTurn({
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory,
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory, assertSessionValid,
}) {
const config = buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist });
let opencode = null;
@@ -596,6 +596,9 @@ async function runOpenCodeTurn({
if (!sessionId) throw new Error("OpenCode did not create a session");
emitter.sessionId(sessionId);
// No awaits between this guard and promptAsync below. Validate before
// starting the background event loop so failure cannot leave it pending.
assertSessionValid?.();
const stopEventLoopWait = createStopWait();
const eventLoop = (async () => {
const iterator = events.stream?.[Symbol.asyncIterator]?.();
@@ -0,0 +1,188 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
function fixture() {
const privacy = createAiPrivacyService();
privacy.checkSession("chat");
const errors = [];
const sends = [];
const emitter = new Proxy({ sessionId() {}, emitError: (error) => errors.push(error) }, {
get: (target, key) => target[key] || (() => {}),
});
return {
privacy, errors, sends, emitter,
assertSessionValid: () => privacy.checkSession("chat"),
enable: () => privacy.configure({ enabled: true }),
assertBlocked() {
assert.deepEqual(sends, []);
assert.match(errors.join("\n"), /start a new chat/);
},
};
}
function gate() {
let entered;
let release;
const ready = new Promise((resolve) => { entered = resolve; });
const waiting = new Promise((resolve) => { release = resolve; });
return { ready, release, async wait() { entered(); await waiting; } };
}
async function toggleDuringStartup(f, pause, run) {
await pause.ready;
f.enable();
pause.release();
await run;
f.assertBlocked();
}
test("Codex SDK rechecks after runStreamed before starting its lazy outbound iterator", async () => {
const { runCodexTurn } = require("./codexDriver.cjs");
const f = fixture();
const pause = gate();
class Codex {
startThread() {
return {
async runStreamed(prompt) {
await pause.wait();
return { events: (async function* () { f.sends.push(prompt); yield {}; })() };
},
};
}
}
await toggleDuringStartup(f, pause, runCodexTurn({
...f, prompt: "raw-secret", CodexCtor: Codex,
}));
});
test("Copilot rechecks after paused session startup before sendAndWait", async () => {
const { runCopilotTurn } = require("./copilotDriver.cjs");
const f = fixture();
const pause = gate();
class CopilotClient {
async createSession() {
await pause.wait();
return { sessionId: "native", sendAndWait: (input) => { f.sends.push(input); } };
}
async stop() {}
}
await toggleDuringStartup(f, pause, runCopilotTurn({
...f, prompt: "raw-secret", sdkModule: { CopilotClient },
}));
});
test("Cursor rechecks inside the environment lock after paused Agent.create", async () => {
const { runCursorTurnInProcess } = require("./cursorDriver.cjs");
const f = fixture();
const pause = gate();
const Agent = {
async create() {
await pause.wait();
return { agentId: "native", send: (input) => { f.sends.push(input); }, close() {} };
},
};
await toggleDuringStartup(f, pause, runCursorTurnInProcess({
...f, prompt: "raw-secret", sdkModule: { Agent }, agentOptions: {},
}));
});
test("CodeBuddy validates prompt-bearing session options after asynchronous SDK loading", async () => {
const { CodebuddySessionManager } = require("./codebuddySessionManager.cjs");
const f = fixture();
const pause = gate();
const manager = new CodebuddySessionManager({
loadSdk: async () => {
await pause.wait();
return {
unstable_v2_createSession: (input) => { f.sends.push(input); },
unstable_v2_resumeSession() {},
};
},
});
const run = manager.runTurn({ ...f, sessionKey: "chat", prompt: "raw-secret", options: {}, sessionOptions: { systemPrompt: "raw-secret" } });
const rejected = assert.rejects(run, /start a new chat/);
await pause.ready;
f.enable();
pause.release();
await rejected;
assert.deepEqual(f.sends, []);
});
test("CodeBuddy rechecks before sending into a session obtained asynchronously", async () => {
const { CodebuddySessionManager } = require("./codebuddySessionManager.cjs");
const f = fixture();
const pause = gate();
const manager = new CodebuddySessionManager();
manager.getOrCreateSession = async () => {
await pause.wait();
return { sessionId: "native", send: (input) => { f.sends.push(input); } };
};
await toggleDuringStartup(f, pause, manager.runTurn({
...f, sessionKey: "chat", prompt: "raw-secret", options: {}, sessionOptions: {},
}));
});
for (const backend of ["opencode", "mimo"]) {
test(`${backend} rechecks after paused session creation before promptAsync`, async () => {
const runTurn = backend === "opencode"
? require("./opencodeDriver.cjs").runOpenCodeTurn
: require("./mimoDriver.cjs").runMimoTurn;
const f = fixture();
const pause = gate();
let closed = false;
const factory = async () => ({
client: {
global: { event: async () => ({ stream: (async function* () { yield { type: "server.connected" }; })() }) },
session: {
create: async () => { await pause.wait(); return { id: "native" }; },
promptAsync: (input) => { f.sends.push(input); return Promise.resolve({}); },
},
},
server: { close() { closed = true; } },
});
await toggleDuringStartup(f, pause, runTurn({
...f, prompt: "raw-secret", systemPrompt: "raw-system",
[backend === "opencode" ? "openCodeFactory" : "mimoFactory"]: factory,
}));
assert.equal(closed, true);
});
}
for (const pausedMethod of ["initialize", "session/new"]) {
test(`Grok ACP validates after paused ${pausedMethod} before releasing semantic content`, async () => {
const { runGrokAcpTurn } = require("./grokAcpDriver.cjs");
const f = fixture();
const pause = gate();
const run = runGrokAcpTurn({
...f, prompt: "raw-secret", systemPrompt: "raw-system", binPath: "/bin/grok",
rpcClientFactory: () => ({
async request(method, params) {
if (method === pausedMethod) await pause.wait();
else if (method === "session/new" || method === "session/prompt") f.sends.push({ method, params });
return method === "initialize" ? {} : { sessionId: "native" };
},
}),
});
await toggleDuringStartup(f, pause, run);
});
}
test("query and argv drivers reject invalid sessions at their outbound handoff", async (t) => {
const cases = [
["claude", (f) => require("./claudeDriver.cjs").runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
["codebuddy", (f) => require("./codebuddyDriver.cjs").runCodebuddyTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
["cursor-cli", (f) => require("./cursorCliDriver.cjs").runCursorCliTurn({ ...f, binPath: "/bin/cursor", workspaceCwd: "/tmp", spawnImpl: (...input) => { f.sends.push(input); } })],
["grok-cli", (f) => require("./grokDriver.cjs").runGrokTurn({ ...f, binPath: "/bin/grok", spawnImpl: (...input) => { f.sends.push(input); } })],
];
for (const [backend, run] of cases) {
await t.test(backend, async () => {
const f = fixture();
f.enable();
await run({ ...f, prompt: "raw-secret" });
f.assertBlocked();
});
}
});
@@ -602,7 +602,58 @@ function shouldReplaySdkHistory({
return !hasInMemorySession;
}
const PRIVATE_RESUME_ERROR = "AI privacy cannot safely resume this agent session. Start a new conversation.";
const PRIVATE_ATTACHMENT_ERROR = "AI privacy does not support direct agent attachments. Remove the attachment before continuing.";
function assertPrivateAttachments(attachments, privacyEnabled) {
// Every staged file can also be read directly by the agent runtime. Even a
// text MIME label does not make its raw base64/file payload a protected path.
if (privacyEnabled && Array.isArray(attachments) && attachments.length > 0) {
throw new Error(PRIVATE_ATTACHMENT_ERROR);
}
}
/** Batch discovery while preserving original schema/question identifiers.
*
* Each scalar carries its complete key ancestry so credentials.value remains
* secret. All scalars are inspected together, including earlier unlabelled
* copies of a later password field. The redacted wrappers are discarded:
* original routing keys never have to cross the content transformation.
*/
function redactSdkAnswerContent(privacyService, chatSessionId, value) {
const content = [];
let nodes = 0;
const prepare = (entry, keys, depth = 0) => {
if (++nodes > 100000 || depth > 100) {
throw new Error("AI privacy could not safely process this value");
}
if (Array.isArray(entry)) {
const children = entry.map((item) => prepare(item, keys, depth + 1));
return (protectedContent) => children.map((rebuild) => rebuild(protectedContent));
}
if (entry && typeof entry === "object") {
const children = Object.entries(entry).map(([key, item]) => [key, prepare(item, [...keys, key], depth + 1)]);
return (protectedContent) => Object.fromEntries(children.map(([key, rebuild]) => [key, rebuild(protectedContent)]));
}
if (entry === undefined) return () => undefined;
const index = content.length;
content.push(keys.reduceRight((nested, key) => ({ [key]: nested }), entry));
return (protectedContent) => {
let result = protectedContent[index];
// Every contextual wrapper has exactly one field. Its possibly masked
// name is irrelevant; this avoids numeric-key reordering ambiguity too.
for (let depth = 0; depth < keys.length; depth += 1) {
result = Object.values(result)[0];
}
return result;
};
};
const rebuild = prepare(value, []);
return rebuild(privacyService.redact(chatSessionId, content));
}
function registerSdkStreamHandlers(ctx) {
const privacyService = ctx.aiPrivacyService || require("../../../shared/aiPrivacyService.cjs");
const injectCliEnv = typeof ctx.withCliDiscoveryEnv === "function"
? ctx.withCliDiscoveryEnv
: (env) => env;
@@ -612,12 +663,40 @@ function registerSdkStreamHandlers(ctx) {
const sdkRequestSessions = new Map(); // requestId -> chatSessionId
const sdkRequestRuntimes = new Map(); // requestId -> { backendKey, codexRuntime }
const sdkSessionIds = new Map(); // chatSessionId -> last sessionId
// Native histories outlive the app, but the private reverse map does not.
// Only native IDs observed in this process under the current chat mapping
// are eligible for a protected resume.
const sdkPrivateSessionIds = new Map();
const codexAppServerRuntime = new CodexAppServerRuntime({
appVersion: electronModule?.app?.getVersion?.() || "0.0.0",
transformUserInputAnswers(answers, context) {
privacyService.checkSession(context.chatSessionId);
return redactSdkAnswerContent(privacyService, context.chatSessionId, answers);
},
sendInteractionRequest(payload, context) {
const sender = context?.sender;
if (!sender || sender.isDestroyed?.()) return false;
safeSend(sender, "netcatty:ai:codex-app-server:interaction-request", payload);
// Native approval arguments must remain exact: accepting them cannot
// replace the command/path that Codex will actually execute.
const displayed = { ...payload };
if (privacyService.checkSession(context.chatSessionId)) {
const restore = (value) => privacyService.restore(context.chatSessionId, value);
if (Array.isArray(displayed.questions)) {
displayed.questions = displayed.questions.map((question) => ({
...question,
...(typeof question.question === "string" ? { question: restore(question.question) } : {}),
...(typeof question.header === "string" ? { header: restore(question.header) } : {}),
...(Array.isArray(question.options) ? {
options: question.options.map((option) => ({
...option,
...(typeof option.label === "string" ? { label: restore(option.label) } : {}),
...(typeof option.description === "string" ? { description: restore(option.description) } : {}),
})),
} : {}),
}));
}
}
safeSend(sender, "netcatty:ai:codex-app-server:interaction-request", displayed);
return true;
},
sendInteractionCleared(payload, context) {
@@ -655,8 +734,17 @@ function registerSdkStreamHandlers(ctx) {
sdkRequestSessions.set(requestId, chatSessionId);
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
const emitter = createStreamEmitter({ safeSend, sender: event.sender, requestId });
let privacyEnabled = false;
let emitter = createStreamEmitter({ safeSend, sender: event.sender, requestId });
try {
privacyEnabled = privacyService.checkSession(chatSessionId);
if (privacyEnabled) {
emitter = createStreamEmitter({
safeSend, sender: event.sender, requestId,
restoreValue: (value) => privacyService.restore(chatSessionId, value),
});
}
assertPrivateAttachments(payload?.images, privacyEnabled);
const shellEnv = await getShellEnv();
const effectiveMode = normalizeToolIntegrationMode(toolIntegrationMode);
setToolIntegrationMode(effectiveMode);
@@ -799,6 +887,10 @@ function registerSdkStreamHandlers(ctx) {
cliMode: cursorCliMode,
hasConfiguredCommand,
});
if (privacyEnabled && resumeSessionId
&& sdkPrivateSessionIds.get(sdkSessionKey) !== resumeSessionId) {
throw new Error(PRIVATE_RESUME_ERROR);
}
const stagedAttachments = [];
const replayHistory = shouldReplaySdkHistory({
backendKey,
@@ -840,6 +932,10 @@ function registerSdkStreamHandlers(ctx) {
...emitter,
sessionId(sessionId) {
if (sessionId) {
// Record before turn completion so Stop cannot lose the native
// ID or its private-history provenance.
sdkSessionIds.set(sdkSessionKey, sessionId);
if (privacyEnabled) sdkPrivateSessionIds.set(sdkSessionKey, sessionId);
emitter.emitEvent({
type: "session-id",
sessionId,
@@ -874,9 +970,41 @@ function registerSdkStreamHandlers(ctx) {
contextualPrompt,
systemContext,
});
// Environment/MCP initialization awaited above. A privacy toggle
// during that work must not send a prompt under the old policy.
privacyService.checkSession(chatSessionId);
let protectedHistorySeed = historySeed;
let protectedAgents = agents;
if (privacyEnabled) {
// Discover secrets across every semantic prompt surface before
// masking any of them. A password defined only in the system
// context must also protect its earlier bare copy in user text.
const semanticText = [promptPlacement.prompt, promptPlacement.systemPrompt || "", historySeed];
const agentFields = [];
if (agents && typeof agents === "object" && !Array.isArray(agents)) {
protectedAgents = Object.fromEntries(Object.entries(agents).map(([name, definition]) => {
if (!definition || typeof definition !== "object") return [name, definition];
const protectedDefinition = { ...definition };
for (const field of ["prompt", "description"]) {
if (typeof definition[field] !== "string") continue;
agentFields.push({ target: protectedDefinition, field, index: semanticText.length });
semanticText.push(definition[field]);
}
return [name, protectedDefinition];
}));
}
const protectedText = privacyService.redact(chatSessionId, semanticText);
promptPlacement.prompt = protectedText[0];
if (promptPlacement.systemPrompt !== undefined) promptPlacement.systemPrompt = protectedText[1];
protectedHistorySeed = protectedText[2];
for (const { target, field, index } of agentFields) target[field] = protectedText[index];
}
const commonTurnContext = {
requestId,
chatSessionId,
// Drivers recheck after their own asynchronous startup, at the
// final Netcatty-owned prompt handoff (including disabled turns).
assertSessionValid: () => privacyService.checkSession(chatSessionId),
...promptPlacement,
cwd: cwd || process.cwd(),
model: model || undefined,
@@ -886,7 +1014,7 @@ function registerSdkStreamHandlers(ctx) {
cursorAuthMode: backendKey === "cursor" ? cursorAuthMode : undefined,
cursorCliBinPath: backendKey === "cursor" ? cursorCliBinPath : undefined,
grokRuntime: backendKey === "grok" ? grokRuntime : undefined,
historySeed: backendKey === "grok" ? historySeed : undefined,
historySeed: backendKey === "grok" ? protectedHistorySeed : undefined,
getTempDir: () => tempDirBridge.getTempDir(),
injectedMcpServers,
claudeSettings,
@@ -913,7 +1041,7 @@ function registerSdkStreamHandlers(ctx) {
maxBudgetUsd: maxBudgetUsd || undefined,
fallbackModel: fallbackModel || undefined,
sandbox: sandbox || undefined,
agents: agents || undefined,
agents: protectedAgents || undefined,
outputFormat: outputFormat || undefined,
enableFileCheckpointing: enableFileCheckpointing != null ? enableFileCheckpointing : undefined,
traceId: traceId || undefined,
@@ -926,7 +1054,10 @@ function registerSdkStreamHandlers(ctx) {
// Persist any new session id for resume on the next turn.
const newSessionId = result?.sessionId || result?.threadId;
if (newSessionId) sdkSessionIds.set(sdkSessionKey, newSessionId);
if (newSessionId) {
sdkSessionIds.set(sdkSessionKey, newSessionId);
if (privacyEnabled) sdkPrivateSessionIds.set(sdkSessionKey, newSessionId);
}
return { ok: true };
} catch (err) {
@@ -1188,6 +1319,13 @@ function registerSdkStreamHandlers(ctx) {
return { status: "unsupported" };
}
let privacyEnabled;
try {
privacyEnabled = privacyService.checkSession(chatSessionId);
assertPrivateAttachments(payload?.images, privacyEnabled);
} catch (error) {
return { status: "failed", message: error.message };
}
const stagedAttachments = [];
const steerPrompt = buildSdkTurnPrompt({
prompt,
@@ -1197,9 +1335,15 @@ function registerSdkStreamHandlers(ctx) {
onStagedAttachment: (attachment) => stagedAttachments.push(attachment),
});
mcpServerBridge.updateAttachmentMetadata?.(stagedAttachments, chatSessionId);
let protectedSteerPrompt;
try {
protectedSteerPrompt = privacyEnabled ? privacyService.redact(chatSessionId, steerPrompt) : steerPrompt;
} catch (error) {
return { status: "failed", message: error.message };
}
const result = await codexAppServerRuntime.steerTurn(requestId, {
chatSessionId,
prompt: steerPrompt,
prompt: protectedSteerPrompt,
attachments: stagedAttachments,
clientUserMessageId,
});
@@ -1244,6 +1388,10 @@ function registerSdkStreamHandlers(ctx) {
sdkRequestRuntimes.delete(requestId);
}
deleteSdkSessionKeysForChat(sdkSessionIds, chatSessionId);
// This also runs when sync replaces runtime state for a retained chat.
// Keep privacy mappings and native-history provenance until explicit chat
// deletion clears the shared privacy service. Its tombstone then prevents
// any stale native ID from being reused.
codebuddySessionManager.closeForChat(chatSessionId);
await codexAppServerRuntime.cleanupChatSession(chatSessionId);
await mcpServerBridge.cleanupScopedMetadata(chatSessionId);
@@ -1252,8 +1400,12 @@ function registerSdkStreamHandlers(ctx) {
ipcMain.handle("netcatty:ai:codex-app-server:interaction-response", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const ok = codexAppServerRuntime.respondInteraction(payload?.interactionId, payload, event.sender);
return ok ? { ok: true } : { ok: false, error: "Interaction not found" };
try {
const ok = codexAppServerRuntime.respondInteraction(payload?.interactionId, payload, event.sender);
return ok ? { ok: true } : { ok: false, error: "Interaction not found" };
} catch (error) {
return { ok: false, error: error.message };
}
});
ipcMain.handle("netcatty:ai:codex-app-server:interaction-cancel-timeout", async (event, payload) => {
@@ -1349,9 +1501,20 @@ function registerSdkStreamHandlers(ctx) {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const { elicitationId, action, content } = payload || {};
if (!elicitationId) return { ok: false, error: "Missing elicitationId" };
const pending = codebuddySessionManager.elicitationPending.get(elicitationId);
if (!pending) return { ok: false, error: "Elicitation not found or already resolved" };
let protectedContent = content;
if (content !== undefined && content !== null) {
try {
privacyService.checkSession(pending.chatSessionId);
protectedContent = redactSdkAnswerContent(privacyService, pending.chatSessionId, content);
} catch (error) {
return { ok: false, error: error.message };
}
}
const resolved = codebuddySessionManager.resolveElicitation(elicitationId, {
action: action || "cancel",
content: content || undefined,
content: protectedContent || undefined,
});
return resolved ? { ok: true } : { ok: false, error: "Elicitation not found or already resolved" };
});
@@ -32,6 +32,13 @@ function registerWithStubbedCtx(overrides = {}) {
ipcMain: { handle: (channel, fn) => handlers.set(channel, fn) },
electronModule: undefined,
validateSender: () => true,
aiPrivacyService: {
isEnabled: () => false,
checkSession: () => false,
redact: (_chatId, value) => value,
restore: (_chatId, value) => value,
clearSession: () => {},
},
mcpServerBridge: {
setChatSessionCancelled: () => {},
cancelPtyExecsForSession: () => {},
@@ -44,6 +51,442 @@ function registerWithStubbedCtx(overrides = {}) {
return { handlers, ctx };
}
function createTestPrivacy(enabled = true) {
const sessions = new Map();
const cleared = new Set();
const checkSession = (chatId) => {
if (cleared.has(chatId)) throw new Error("AI privacy session was cleared. Start a new conversation.");
const prior = sessions.get(chatId);
if (prior && prior.enabled !== enabled) throw new Error("AI privacy settings changed. Start a new conversation.");
if (!prior) sessions.set(chatId, { enabled, values: new Map(), tokens: new Map() });
return enabled;
};
const mapValues = (value, fn) => {
if (typeof value === "string") return fn(value);
if (Array.isArray(value)) return value.map((entry) => mapValues(entry, fn));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, mapValues(entry, fn)]));
}
return value;
};
return {
isEnabled: () => enabled,
hasSession: (chatId) => sessions.has(chatId),
setEnabled: (value) => { enabled = value; },
checkSession,
clearSession(chatId) { sessions.delete(chatId); cleared.add(chatId); },
redact(chatId, value) {
if (!checkSession(chatId)) return value;
const state = sessions.get(chatId);
return mapValues(value, (text) => text.replace(/secret-[a-z]+/g, (original) => {
if (!state.values.has(original)) {
const token = `[NC_${chatId}_SECRET_${state.values.size + 1}]`;
state.values.set(original, token);
state.tokens.set(token, original);
}
return state.values.get(original);
}));
},
restore(chatId, value) {
if (!checkSession(chatId)) return value;
const state = sessions.get(chatId);
return mapValues(value, (text) => text.replace(/\[NC_[^\]]*(?:\]|$)/g, (token) => {
if (!state.tokens.has(token)) throw new Error("Unknown AI privacy token.");
return state.tokens.get(token);
}));
},
};
}
function createPrivacyStreamHarness(privacy = createTestPrivacy(), runTurn) {
const turns = [];
const messages = [];
const driver = {
async runTurn(context) {
turns.push(context);
if (runTurn) return runTurn(context);
context.emitter.sessionId("native-1");
context.emitter.text(context.prompt);
context.emitter.emitDone();
return { sessionId: "native-1" };
},
};
const registered = registerWithStubbedCtx({
aiPrivacyService: privacy,
safeSend: (_sender, channel, payload) => messages.push({ channel, payload }),
getShellEnv: async () => ({}),
normalizeToolIntegrationMode: (mode) => mode || "mcp",
setToolIntegrationMode: () => {},
normalizeAgentEnv: (env) => ({ ...env }),
normalizeClaudeCodeExecutableEnvForSdk: (env) => env,
resolveCliFromPath: (backend) => `/bin/${backend}`,
normalizeCliPathForPlatform: (value) => value,
resolveSdkBinPath: (backend) => `/bin/${backend}`,
resolveClaudeCodeExecutableForSdk: (value) => value,
resolveCodexExecutableForSdk: (value) => value,
resolveCodebuddyExecutableForSdk: (value) => value,
addCodexExecutableEnvForSdk: (env) => env,
buildExternalAgentSystemContext: ({ userSkillsContext, defaultTargetSession }) => (
`System ${userSkillsContext || ""} ${defaultTargetSession?.hostname || ""}`
),
buildExternalAgentContextualPrompt: ({ prompt, userSkillsContext, defaultTargetSession }) => (
`System ${userSkillsContext || ""} ${defaultTargetSession?.hostname || ""}\n\n${prompt}`
),
getDriver: () => driver,
mcpServerBridge: {
setChatSessionCancelled: () => {},
cancelPtyExecsForSession: () => {},
cancelWorkerBackgroundJobsForSession: () => {},
clearPendingApprovals: () => {},
cleanupScopedMetadata: async () => {},
getOrCreateHost: async () => 1234,
getScopedSessionIds: () => ["terminal-1"],
buildMcpServerConfig: (_port, _ids, chatId) => ({
name: "netcatty", command: "/bin/node", env: [{ name: "CHAT_ID", value: chatId }],
}),
updateAttachmentMetadata: () => {},
},
});
const stream = (overrides = {}) => registered.handlers.get("netcatty:ai:sdk-agent:stream")(
{ sender: {} },
{
requestId: `request-${turns.length + 1}`,
chatSessionId: "chat-1",
sdkBackend: "claude",
prompt: "Use secret-user",
cwd: "/secret-runtime",
agentEnv: { RUNTIME_AUTH: "secret-auth" },
userSkillsContext: "secret-skill",
defaultTargetSession: { hostname: "secret-host" },
historyMessages: [{ role: "user", content: "Previous secret-history" }],
...overrides,
},
);
return { ...registered, stream, turns, messages, privacy };
}
test("SDK privacy protects assembled prompt/history/system content across drivers without touching runtime routing", async () => {
for (const backend of ["claude", "codex", "copilot", "cursor", "codebuddy", "opencode", "grok", "mimo"]) {
const h = createPrivacyStreamHarness();
const result = await h.stream({ sdkBackend: backend });
assert.equal(result.ok, true, `${backend}: ${result.error}`);
assert.equal(h.turns.length, 1);
const turn = h.turns[0];
assert.doesNotMatch(turn.prompt, /secret-/);
assert.doesNotMatch(turn.systemPrompt || "", /secret-/);
assert.equal(turn.cwd, "/secret-runtime");
assert.equal(turn.env.RUNTIME_AUTH, "secret-auth");
assert.equal(turn.chatSessionId, "chat-1");
assert.match(JSON.stringify(h.messages), /secret-user/, "local human output is restored");
assert.doesNotMatch(JSON.stringify(h.messages), /\[NC_/, "aliases do not escape into local display");
}
});
test("SDK privacy preserves aliases and safe native resume across turns, including Grok history fallback", async () => {
const h = createPrivacyStreamHarness();
assert.equal((await h.stream({ sdkBackend: "grok" })).ok, true);
assert.equal((await h.stream({ sdkBackend: "grok" })).ok, true);
assert.equal(h.turns[1].resumeSessionId, "native-1");
const token = h.privacy.redact("chat-1", "secret-user");
assert.ok(h.turns[0].prompt.includes(token));
assert.ok(h.turns[1].prompt.includes(token));
assert.doesNotMatch(h.turns[1].historySeed, /secret-history/);
assert.match(h.turns[1].historySeed, /\[NC_/);
});
test("SDK adapters round trip through the real shared privacy service", async () => {
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
const privacy = createAiPrivacyService();
privacy.configure({ enabled: true, patterns: ["secret-[a-z]+"] });
const h = createPrivacyStreamHarness(privacy);
assert.equal((await h.stream()).ok, true);
assert.equal((await h.stream()).ok, true);
assert.equal(h.turns[1].resumeSessionId, "native-1");
assert.doesNotMatch(h.turns[0].prompt, /secret-/);
assert.match(JSON.stringify(h.messages), /secret-user/);
assert.doesNotMatch(JSON.stringify(h.messages), /\[NC_/);
});
test("CodeBuddy nested agent prompts are protected without changing agent names, tools or model IDs", async () => {
const h = createPrivacyStreamHarness();
const agents = {
"secret-agent": {
prompt: "Use secret-user",
description: "Access secret-host",
tools: ["secret-tool"],
model: "secret-model",
},
};
assert.equal((await h.stream({ sdkBackend: "codebuddy", agents })).ok, true);
const definition = h.turns[0].agents["secret-agent"];
assert.doesNotMatch(definition.prompt, /secret-user/);
assert.doesNotMatch(definition.description, /secret-host/);
assert.deepEqual(definition.tools, ["secret-tool"]);
assert.equal(definition.model, "secret-model");
});
test("real privacy discovers system and custom-agent secrets before masking any prompt surface", async () => {
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
for (const source of ["system", "custom-agent"]) {
const service = createAiPrivacyService();
service.configure({ enabled: true });
const batches = [];
const privacy = {
...service,
redact(chatId, value) { batches.push(value); return service.redact(chatId, value); },
};
const h = createPrivacyStreamHarness(privacy);
const result = await h.stream({
sdkBackend: source === "system" ? "opencode" : "codebuddy",
prompt: "Use synthetic-secret",
historyMessages: [{ role: "user", content: "Earlier synthetic-secret" }],
userSkillsContext: source === "system" ? "password=synthetic-secret" : "",
agents: {
helper: {
prompt: "Check synthetic-secret",
description: source === "custom-agent" ? "password=synthetic-secret" : "Helper",
},
},
});
assert.equal(result.ok, true, result.error);
const turn = h.turns[0];
assert.doesNotMatch(turn.prompt, /synthetic-secret/);
assert.doesNotMatch(turn.systemPrompt, /synthetic-secret/);
assert.doesNotMatch(turn.agents.helper.prompt, /synthetic-secret/);
assert.doesNotMatch(turn.agents.helper.description, /synthetic-secret/);
assert.equal(batches.length, 1, "all semantic prompt surfaces share one discovery pass");
assert.ok(Array.isArray(batches[0]));
}
});
test("real privacy retains mapping and native resume provenance through runtime-only cleanup", async () => {
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
const privacy = createAiPrivacyService();
privacy.configure({ enabled: true, patterns: ["secret-[a-z]+"] });
const h = createPrivacyStreamHarness(privacy);
assert.equal((await h.stream()).ok, true);
const token = privacy.redact("chat-1", "secret-user");
await h.handlers.get("netcatty:ai:sdk-agent:cleanup")({}, { chatSessionId: "chat-1" });
assert.equal(privacy.hasSession("chat-1"), true);
assert.equal(privacy.restore("chat-1", token), "secret-user");
const resumed = await h.stream({ existingSessionId: "native-1" });
assert.equal(resumed.ok, true, resumed.error);
assert.equal(h.turns[1].resumeSessionId, "native-1");
assert.ok(h.turns[1].prompt.includes(token));
privacy.clearSession("chat-1");
const deleted = await h.stream({ existingSessionId: "native-1" });
assert.equal(deleted.ok, false);
assert.match(deleted.error, /new chat|new conversation/i);
assert.equal(h.turns.length, 2);
});
test("privacy policy change while environment initialization awaits blocks the provider call", async () => {
const h = createPrivacyStreamHarness(createTestPrivacy(false));
let release;
let notifyStarted;
const started = new Promise((resolve) => { notifyStarted = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
h.ctx.getShellEnv = async () => { notifyStarted(); await gate; return {}; };
const run = h.stream();
await started;
h.privacy.setEnabled(true);
release();
const result = await run;
assert.equal(result.ok, false);
assert.match(result.error, /settings changed/);
assert.equal(h.turns.length, 0);
assert.equal(h.ctx.sdkActiveStreams.size, 0);
});
test("native session emitted before a stopped turn remains eligible for private resume", async () => {
let count = 0;
const h = createPrivacyStreamHarness(createTestPrivacy(), async (context) => {
context.emitter.sessionId("native-stopped");
if (++count === 1) throw new Error("Stopped");
context.emitter.emitDone();
return { sessionId: "native-stopped" };
});
assert.equal((await h.stream()).ok, false);
assert.equal((await h.stream()).ok, true);
assert.equal(h.turns[1].resumeSessionId, "native-stopped");
});
test("SDK privacy refuses cold native resumes even if Catty already established a chat mapping", async () => {
const privacy = createTestPrivacy();
privacy.redact("chat-1", "secret-user");
const h = createPrivacyStreamHarness(privacy);
const result = await h.stream({ existingSessionId: "persisted-native-thread" });
assert.equal(result.ok, false);
assert.match(result.error, /Start a new conversation/);
assert.equal(h.turns.length, 0);
});
test("SDK privacy blocks direct image and file attachments before driver or staging", async () => {
for (const image of [
{ mediaType: "image/png", base64Data: "c2VjcmV0", filename: "image.png" },
{ mediaType: "text/plain", base64Data: "c2VjcmV0", filename: "notes.txt" },
{ filePath: "/private/image.png", mediaType: "image/png" },
]) {
const h = createPrivacyStreamHarness();
const result = await h.stream({ images: [image] });
assert.equal(result.ok, false);
assert.match(result.error, /does not support direct agent attachments/);
assert.equal(h.turns.length, 0);
}
});
test("SDK privacy changes require a new chat and cancellation preserves the existing map", async () => {
const h = createPrivacyStreamHarness();
assert.equal((await h.stream()).ok, true);
await h.handlers.get("netcatty:ai:sdk-agent:cancel")({}, { requestId: "missing", chatSessionId: "chat-1" });
assert.equal(h.privacy.hasSession("chat-1"), true);
h.privacy.setEnabled(false);
const result = await h.stream();
assert.equal(result.ok, false);
assert.match(result.error, /settings changed/);
assert.equal(h.turns.length, 1);
await h.handlers.get("netcatty:ai:sdk-agent:cleanup")({}, { chatSessionId: "chat-1" });
assert.equal(h.privacy.hasSession("chat-1"), true, "runtime cleanup is not chat deletion");
});
test("disabled SDK privacy leaves prompts, native resumes, and output untouched", async () => {
const h = createPrivacyStreamHarness(createTestPrivacy(false));
const result = await h.stream({ existingSessionId: "existing-native-thread", prompt: "literal [NC_" });
assert.equal(result.ok, true);
assert.equal(h.turns[0].resumeSessionId, "existing-native-thread");
assert.match(h.turns[0].prompt, /secret-skill/);
assert.match(JSON.stringify(h.messages), /literal \[NC_/);
});
test("Codex app-server start and steering use the same privacy scope", async () => {
const h = createPrivacyStreamHarness();
h.ctx.codexAppServerRuntime.runTurn = async (context) => {
h.turns.push(context);
context.emitter.sessionId("app-native-1");
return { threadId: "app-native-1" };
};
assert.equal((await h.stream({ sdkBackend: "codex", codexRuntime: "app-server" })).ok, true);
const token = h.privacy.redact("chat-1", "secret-user");
assert.ok(h.turns[0].prompt.includes(token));
h.ctx.sdkRequestSessions.set("active-request", "chat-1");
h.ctx.sdkRequestRuntimes.set("active-request", {
backendKey: "codex", codexRuntime: "app-server", toolIntegrationMode: "mcp",
});
let steered;
h.ctx.codexAppServerRuntime.steerTurn = async (_requestId, input) => {
steered = input;
return { status: "accepted" };
};
const steer = h.handlers.get("netcatty:ai:sdk-agent:steer");
const result = await steer({}, {
requestId: "active-request", chatSessionId: "chat-1",
clientUserMessageId: "user-1", prompt: "Also secret-user",
});
assert.equal(result.status, "accepted");
assert.equal(steered.prompt, `Also ${token}`);
assert.equal(steered.clientUserMessageId, "user-1");
const blocked = await steer({}, {
requestId: "active-request", chatSessionId: "chat-1", clientUserMessageId: "user-2",
prompt: "image", images: [{ mediaType: "image/png", base64Data: "aA==" }],
});
assert.equal(blocked.status, "failed");
});
test("Codex and CodeBuddy user answers are masked using the pending chat without rewriting field IDs", async () => {
const h = createPrivacyStreamHarness();
h.privacy.checkSession("chat-1");
const content = { "secret-question": { answers: ["secret-user"] } };
const answers = h.ctx.codexAppServerRuntime.transformUserInputAnswers(content, { chatSessionId: "chat-1" });
assert.deepEqual(Object.keys(answers), ["secret-question"]);
assert.equal(answers["secret-question"].answers[0], h.privacy.redact("chat-1", "secret-user"));
let response;
h.ctx.codebuddySessionManager.elicitationPending.set("elicit-private", {
chatSessionId: "chat-1",
resolve: (value) => { response = value; },
});
const result = await h.handlers.get("netcatty:ai:sdk-agent:elicitation-response")({}, {
elicitationId: "elicit-private", action: "accept", content,
});
assert.equal(result.ok, true);
assert.deepEqual(response.content, answers);
});
test("real privacy batches user answers with secret ancestry and preserves all question IDs", async () => {
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
for (const route of ["codex", "codebuddy"]) {
for (const content of [
{ echo: "synthetic-secret", password: "synthetic-secret" },
{ echo: "synthetic-secret", credentials: { value: "synthetic-secret" } },
{
"synthetic-secret": { answers: ["synthetic-secret"] },
credentials: { value: "synthetic-secret" },
},
]) {
const service = createAiPrivacyService();
service.configure({ enabled: true });
let batches = 0;
const privacy = {
...service,
redact(chatId, value) { batches += 1; return service.redact(chatId, value); },
};
const h = createPrivacyStreamHarness(privacy);
let protectedContent;
if (route === "codex") {
protectedContent = h.ctx.codexAppServerRuntime.transformUserInputAnswers(content, { chatSessionId: "chat-1" });
} else {
h.ctx.codebuddySessionManager.elicitationPending.set("batch-private", {
chatSessionId: "chat-1",
resolve(response) { protectedContent = response.content; },
});
const result = await h.handlers.get("netcatty:ai:sdk-agent:elicitation-response")({}, {
elicitationId: "batch-private", action: "accept", content,
});
assert.equal(result.ok, true, result.error);
}
const compare = (original, masked) => {
if (typeof original === "string") {
assert.notEqual(masked, original);
assert.equal(service.restore("chat-1", masked), original);
} else if (Array.isArray(original)) {
original.forEach((entry, index) => compare(entry, masked[index]));
} else {
assert.deepEqual(Object.keys(masked), Object.keys(original));
Object.entries(original).forEach(([key, entry]) => compare(entry, masked[key]));
}
};
compare(content, protectedContent);
assert.equal(batches, 1, "answer values share a single secret-discovery pass");
}
}
});
test("Codex native approval arguments remain exact while question display restores content and preserves IDs", () => {
const h = createPrivacyStreamHarness();
const token = h.privacy.redact("chat-1", "secret-user");
const nativeArgs = {
command: `echo ${token}`,
cwd: `/work/${token}`,
commandActions: [{ type: "read", path: token }],
itemId: token,
};
h.ctx.codexAppServerRuntime.sendInteractionRequest({
interactionId: token,
args: nativeArgs,
questions: [{
id: token, header: token, question: token,
options: [{ label: token, description: token }],
}],
}, { chatSessionId: "chat-1", sender: {} });
const displayed = h.messages.at(-1).payload;
assert.equal(displayed.interactionId, token);
assert.strictEqual(displayed.args, nativeArgs);
assert.equal(displayed.args.itemId, token);
assert.equal(displayed.args.command, `echo ${token}`);
assert.equal(displayed.questions[0].id, token);
assert.equal(displayed.questions[0].question, "secret-user");
assert.equal(displayed.questions[0].options[0].label, "secret-user");
});
test("sdk-agent:cleanup aborts and removes request entries for the target chat only", async () => {
const { handlers, ctx } = registerWithStubbedCtx();
@@ -1607,3 +2050,28 @@ test("list-models persists a first current-only success for later failures", asy
cleanup();
}
});
test("SDK adapter passes a live privacy guard through asynchronous driver startup even when initially disabled", async () => {
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
const privacy = createAiPrivacyService();
let notifyReady;
let release;
let sent = false;
const ready = new Promise((resolve) => { notifyReady = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
const h = createPrivacyStreamHarness(privacy, async (context) => {
notifyReady();
await gate;
context.assertSessionValid();
sent = true;
});
const run = h.stream();
await ready;
privacy.configure({ enabled: true });
release();
const result = await run;
assert.equal(result.ok, false);
assert.match(result.error, /start a new chat/);
assert.equal(sent, false);
assert.equal(h.ctx.sdkActiveStreams.size, 0);
});
@@ -4,6 +4,7 @@ const test = require('node:test');
const { configureTerminalSessionDataEmitter, emitTerminalSessionData } = require('./emitTerminalSessionData.cjs');
const { registerCattyExecHandlers } = require('./aiBridge/cattyExecHandlers.cjs');
const { execViaPty } = require('./ai/ptyExec.cjs');
const { createAiPrivacyService } = require('../shared/aiPrivacyService.cjs');
// Keep bridge discovery writes/deletes off the installed app's live file.
const { isolateCliDiscoveryFile } = require('./cliDiscoveryTestIsolation.cjs');
isolateCliDiscoveryFile();
@@ -46,7 +47,10 @@ for (const surface of ['catty', 'mcp-exec', 'mcp-job']) {
let pending;
if (surface === 'catty') {
const handlers = new Map();
const aiPrivacyService = createAiPrivacyService();
aiPrivacyService.checkSession('chat');
registerCattyExecHandlers({ ipcMain: { handle: (key, fn) => handlers.set(key, fn) },
aiPrivacyService,
validateSender: () => true, sessions, mcpServerBridge: bridge, electronModule,
safeSend: (target, ...args) => target.send(...args), execViaPty, getFreshIdlePrompt: () => '' });
pending = handlers.get('netcatty:ai:exec')({ sender: contents }, { sessionId: 'session', command: 'echo test', chatSessionId: 'chat' });
+5 -2
View File
@@ -1,5 +1,7 @@
"use strict";
const { createPrivacyBoundary } = require("../../mcp/externalMcpPrivacyBoundary.cjs");
/**
* Optional Streamable HTTP endpoint for External MCP.
* Tool calls go through the same progressive MCP registry and
@@ -188,6 +190,7 @@ function createExternalMcpHttpServer(deps = {}) {
}
function createSessionServer() {
const protectedRpcCall = createPrivacyBoundary(rpcCall, deps.getRedactionConfig || (() => undefined));
const {
McpServer,
getCatalogToolDescription,
@@ -205,7 +208,7 @@ function createExternalMcpHttpServer(deps = {}) {
"netcatty://context",
{ description: "Current Netcatty workspace context: connected hosts, session IDs, and environment description." },
async () => {
const ctx = await rpcCall("netcatty/getContext", { chatSessionId });
const ctx = await protectedRpcCall("netcatty/getContext", { chatSessionId });
return {
contents: [{
uri: "netcatty://context",
@@ -216,7 +219,7 @@ function createExternalMcpHttpServer(deps = {}) {
},
);
registerProgressiveMcpTools(server, {
rpcCall,
rpcCall: protectedRpcCall,
scopeParams: { chatSessionId },
guardWriteOperation,
catalogDescription(toolName, fallback) {
@@ -251,6 +251,40 @@ describe("external MCP streamable HTTP server", () => {
}
});
it("isolates reversible masking per HTTP session and covers the environment resource", async () => {
const writes = [];
active = await startServer({
getRedactionConfig: () => ({ enabled: true }),
dispatchRpc: async (method, params) => {
if (method === "netcatty/exec") {
writes.push(params.command);
return { ok: true, stdout: params.command, exitCode: 0 };
}
return { ok: true, text: "192.0.2.10", password: "synthetic-password" };
},
});
const clients = [1, 2].map(() => new Client({ name: "privacy-test", version: "1" }));
try {
for (const client of clients) {
await client.connect(new StreamableHTTPClientTransport(new URL(active.url), {
requestInit: { headers: { Authorization: `Bearer ${TOKEN}` } },
}));
}
const result = await clients[0].callTool({ name: "get_environment", arguments: {} });
const data = JSON.parse(result.content[0].text);
assert.doesNotMatch(JSON.stringify(result), /192\.0\.2\.10|synthetic-password/);
const resource = await clients[0].readResource({ uri: "netcatty://context" });
assert.equal(JSON.parse(resource.contents[0].text).text, data.text);
await clients[0].callTool({ name: "terminal_execute", arguments: { sessionId: "s", command: `echo ${data.text}` } });
assert.deepEqual(writes, ["echo 192.0.2.10"]);
const rejected = await clients[1].callTool({ name: "terminal_execute", arguments: { sessionId: "s", command: `echo ${data.text}` } });
assert.equal(rejected.isError, true);
assert.equal(writes.length, 1);
} finally {
for (const client of clients) await client.close();
}
});
it("expires an HTTP session that never sends DELETE", async () => {
active = await startServer({ sessionIdleMs: 40, sessionSweepMs: 15 });
const initialized = await postJson(active.url, {
+29 -1
View File
@@ -1,6 +1,8 @@
"use strict";
const crypto = require("node:crypto");
const { normalizeRedactionConfig } = require("../mcp/externalMcpRedaction.cjs");
const aiPrivacyService = require("../shared/aiPrivacyService.cjs");
const {
getExternalMcpDiscoveryFilePath,
@@ -81,6 +83,7 @@ function createExternalMcpController(options = {}) {
let discoveryFilePath = null;
let enabled = false;
let redaction = normalizeRedactionConfig();
let state = "disabled";
let error = null;
let mode = EXTERNAL_MCP_MODE_TEMPORARY;
@@ -175,6 +178,7 @@ function createExternalMcpController(options = {}) {
isEnabled: () => isEnabled(),
recordActivity,
getPermissionMode: () => getBridge()?.getPermissionMode?.() || "confirm",
getRedactionConfig: () => redaction,
attachExternalClient: (client) => {
const bridge = getBridge();
if (typeof bridge?.attachExternalMcpClient === "function") {
@@ -192,6 +196,7 @@ function createExternalMcpController(options = {}) {
chatSessionId: deps.chatSessionId,
}, {
externalSocket: context?.externalClient || null,
beforeExecute: aiPrivacyService.createPolicyGuard(),
});
},
});
@@ -316,6 +321,7 @@ function createExternalMcpController(options = {}) {
hostRunning,
error,
http: buildHttpStatus(),
redaction,
};
}
@@ -332,6 +338,7 @@ function createExternalMcpController(options = {}) {
pid: process.pid,
permissionMode: bridge.getPermissionMode?.() || "confirm",
chatSessionId: deps.chatSessionId,
redaction,
});
}
@@ -542,6 +549,26 @@ function createExternalMcpController(options = {}) {
}
function setConfig(config = {}) {
if (config.redaction !== undefined) {
let nextRedaction;
try {
nextRedaction = normalizeRedactionConfig(config.redaction);
} catch {
if (config.redaction === null) aiPrivacyService.invalidateConfiguration();
return { ...buildStatus(), ok: false, error: "Invalid privacy rules. Check CIDRs and regular expressions." };
}
if (JSON.stringify(nextRedaction) !== JSON.stringify(redaction)) {
const previousRedaction = redaction;
redaction = nextRedaction;
try {
writeDiscoveryFromBridge();
} catch {
redaction = previousRedaction;
return { ...buildStatus(), ok: false, error: "Privacy settings could not be saved. Retry before reconnecting clients." };
}
}
aiPrivacyService.configure(nextRedaction);
}
const nextMode = config.mode == null ? mode : normalizeMode(config.mode);
const nextIdleTimeoutMinutes = config.idleTimeoutMinutes == null
? idleTimeoutMinutes
@@ -750,7 +777,8 @@ function createExternalMcpController(options = {}) {
});
ipcMain.handle("netcatty:external-mcp:set-config", async (event, payload) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
setConfig(payload || {});
const status = setConfig(payload || {});
if (!status.ok) return status;
await whenHttpSettled();
return getStatus();
});
@@ -101,6 +101,38 @@ describe("externalMcpController", () => {
return { controller, bridge };
}
it("validates privacy config before updating discovery and propagates IPC failures", async () => {
const { controller } = createController();
await controller.setEnabled(true);
const valid = { enabled: true, redactPrivateIps: true, cidrs: ["10.0.0.0/8"], patterns: ["internal-[a-z]+"] };
assert.equal(controller.setConfig({ redaction: valid }).ok, true);
assert.deepEqual(written.at(-1).options.redaction, valid);
const handlers = new Map();
controller.registerHandlers({ handle: (name, handler) => handlers.set(name, handler) });
const result = await handlers.get("netcatty:external-mcp:set-config")({}, { redaction: { enabled: true, cidrs: ["bad-value"] } });
assert.equal(result.ok, false);
assert.deepEqual(controller.getStatus().redaction, valid);
assert.deepEqual(written.at(-1).options.redaction, valid);
assert.ok(!result.error.includes("bad-value"));
});
it("retries discovery persistence after a failed privacy change", async () => {
let fail = false;
let saved;
const { controller } = createController({ writeDiscovery: (_path, options) => {
if (fail) throw new Error("disk full");
saved = options;
} });
await controller.setEnabled(true);
fail = true;
assert.equal(controller.setConfig({ redaction: { enabled: true } }).ok, false);
assert.equal(controller.getStatus().redaction.enabled, false);
assert.equal(saved.redaction.enabled, false);
fail = false;
assert.equal(controller.setConfig({ redaction: { enabled: true } }).ok, true);
assert.equal(saved.redaction.enabled, true);
});
it("enables external mode, writes discovery, and syncs sessions", async () => {
const { controller, bridge } = createController();
const status = await controller.setEnabled(true);
+33 -11
View File
@@ -63,6 +63,9 @@ const DISCOVERY_SELF_HEAL_INTERVAL_MS = 15000;
// Track which sockets have completed authentication
const authenticatedSockets = new WeakSet();
const managedPrivacyScopes = new WeakMap();
const aiPrivacyService = require("../shared/aiPrivacyService.cjs");
const { createManagedPrivacyBoundary } = require("../shared/managedPrivacyBoundary.cjs");
// Sockets authenticated with the External MCP token (or that used the reserved scope).
const externalMcpSockets = new Set();
const sessionApprovedExternalSockets = new WeakSet();
@@ -1440,7 +1443,7 @@ async function handleMessage(socket, line) {
}
const { id, method, params } = msg;
debugLog("handleMessage", { id, method, params });
debugLog("handleMessage", { id, method });
if (id == null || !method) return;
// ── Authentication gate ──
@@ -1513,9 +1516,25 @@ async function handleMessage(socket, line) {
);
}
notifyExternalMcpActivity(method, callParams);
const result = await dispatch(method, callParams, {
externalSocket: externalMcpSockets.has(socket) ? socket : null,
});
let result;
if (isExternalScope || externalMcpSockets.has(socket)) {
result = await dispatch(method, callParams, {
externalSocket: socket,
beforeExecute: aiPrivacyService.createPolicyGuard(),
});
} else {
const boundScope = managedPrivacyScopes.get(socket);
if (boundScope && boundScope !== callParams.chatSessionId) {
throw new Error("Managed agent connection cannot change its chat session.");
}
if (aiPrivacyService.isEnabled() && callParams.chatSessionId) {
managedPrivacyScopes.set(socket, callParams.chatSessionId);
}
result = await createManagedPrivacyBoundary(
(rpcMethod, rpcParams, context) => dispatch(rpcMethod, rpcParams, context),
aiPrivacyService,
)(method, callParams);
}
const response = JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n";
if (!socket.destroyed) socket.write(response);
} catch (err) {
@@ -1789,7 +1808,7 @@ function buildTerminalToolGuidance(toolHints) {
return "";
}
async function handleWorkerTerminalExec(params = {}) {
async function handleWorkerTerminalExec(params = {}, beforeExecute) {
const { sessionId, command } = params;
if (!sessionId || !command) throw new Error("sessionId and command are required");
if (typeof command !== "string" || !command.trim()) {
@@ -1837,7 +1856,7 @@ async function handleWorkerTerminalExec(params = {}) {
sessionMeta: meta,
enforceWallTimeout: true,
commandBlocklist,
}, {});
}, typeof beforeExecute === "function" ? { beforeExecute } : {});
} catch (err) {
return { ok: false, error: err?.message || String(err) };
} finally {
@@ -1846,7 +1865,7 @@ async function handleWorkerTerminalExec(params = {}) {
}
}
async function handleWorkerJobStart(params = {}) {
async function handleWorkerJobStart(params = {}, beforeExecute) {
const { sessionId, command } = params;
if (!sessionId || !command) throw new Error("sessionId and command are required");
if (typeof command !== "string" || !command.trim()) {
@@ -1888,7 +1907,7 @@ async function handleWorkerJobStart(params = {}) {
commandTimeoutMs,
sessionMeta: meta,
commandBlocklist,
}, {});
}, typeof beforeExecute === "function" ? { beforeExecute } : {});
if (result?.ok && result.jobId) {
if (pendingStart.cancelled || closingTerminalSessions.has(sessionId)) {
await waitForWorkerCleanup(terminalWorkerManager.request("netcatty:ai:jobStop", {
@@ -2236,11 +2255,14 @@ async function dispatch(method, params, approvalContext = {}) {
if (!handler) {
throw new Error(`Unknown method: ${method}`);
}
// Approval may have waited across a privacy-policy change. Check at execution,
// not only when the managed request entered the dispatcher.
approvalContext.beforeExecute?.();
if (capability?.id === "terminal.execute" && params?.sessionId && !sessions?.get?.(params.sessionId)) {
return await handleWorkerTerminalExec(params);
return await handleWorkerTerminalExec(params, approvalContext.beforeExecute);
}
if (capability?.id === "terminal.start" && params?.sessionId && !sessions?.get?.(params.sessionId)) {
return await handleWorkerJobStart(params);
return await handleWorkerJobStart(params, approvalContext.beforeExecute);
}
if (capability?.id === "terminal.poll" && workerBackgroundJobs.has(params?.jobId)) {
return await handleWorkerJobPoll(params);
@@ -2248,7 +2270,7 @@ async function dispatch(method, params, approvalContext = {}) {
if (capability?.id === "terminal.stop" && workerBackgroundJobs.has(params?.jobId)) {
return await handleWorkerJobStop(params);
}
return await handler(params);
return await handler(params, approvalContext);
} finally {
if (activityStarted) {
sessionIdleManager.endActivity(params?.chatSessionId, params.sessionId);
@@ -8,6 +8,7 @@ const test = require("node:test");
const { createBackgroundJobApi } = require("./mcpServerBridge/backgroundJobs.cjs");
const { createExecHandlerApi } = require("./mcpServerBridge/execHandlers.cjs");
const { PROBE_OUTPUT_MARKER } = require("./ai/sessionShellKind.cjs");
const { createAiPrivacyService } = require("../shared/aiPrivacyService.cjs");
const {
checkBlocklistForShell,
resolveSessionBlocklistShellKind,
@@ -266,6 +267,37 @@ test("MCP exec probes an unclassified PowerShell session before the authoritativ
assert.equal(session._loginShellKind, "powershell");
});
for (const route of ["pty", "channel", "job"]) {
test(`MCP ${route} rechecks privacy after shell probing before command handoff`, async () => {
const privacy = createAiPrivacyService();
privacy.checkSession("chat");
const deferred = createDeferredShellProbeConn();
const session = { protocol: "ssh", conn: deferred.conn };
if (route !== "channel") session.stream = new FakePty();
const ctx = createExecHandlerTestContext({
sessions: new Map([["ssh", session]]),
backgroundJobs: new Map(),
});
const effects = [];
ctx.execViaPty = (...args) => { effects.push(args); return { ok: true }; };
ctx.execViaChannel = (...args) => { effects.push(args); return { ok: true }; };
ctx.startPtyJob = (...args) => { effects.push(args); return { resultPromise: Promise.resolve({}) }; };
const api = createExecHandlerApi(ctx);
const handler = route === "job" ? api.handleJobStart : api.handleExec;
const pending = handler({
sessionId: "ssh", command: "echo synthetic", chatSessionId: "chat",
}, { beforeExecute: () => privacy.restore("chat", []) });
await nextTick();
privacy.configure({ enabled: true });
deferred.release();
const result = await Promise.resolve(pending).catch((error) => ({ ok: false, error: error.message }));
assert.equal(result.ok, false);
assert.match(result.error, /start a new chat/);
assert.deepEqual(effects, []);
assert.equal(ctx.activeSessionExecutions.size, 0);
});
}
test("expired local and unknown jobs do not claim a lost worker route", (t) => {
const original = process.env.NETCATTY_JOB_DIAG;
@@ -8,8 +8,11 @@ const net = require("node:net");
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const bridge = require("./mcpServerBridge.cjs");
const privacy = require("../shared/aiPrivacyService.cjs");
test("external MCP approval names the host and isolates connection and host grants", async (t) => {
privacy.configure({ enabled: false });
let executions = 0;
const dir = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("host-approval")}-`);
const clients = [];
t.after(() => {
@@ -30,7 +33,7 @@ test("external MCP approval names the host and isolates connection and host gran
sessions: new Map(),
cliDiscoveryFilePath: path.join(dir, "discovery.json"),
terminalWorkerManager: {
request: async () => ({ ok: true, stdout: "done", stderr: "", exitCode: 0 }),
request: async () => { executions += 1; return { ok: true, stdout: "done", stderr: "", exitCode: 0 }; },
},
});
bridge.setMainWindowGetter(() => ({
@@ -141,6 +144,16 @@ test("external MCP approval names the host and isolates connection and host gran
bridge.resolveApprovalFromRenderer(localRequest.approvalId, false);
assert.equal((await localCall).ok, false);
const policyApproval = nextApproval();
const policyCall = exec(clientC, "local-test");
const rejectedPolicyCall = assert.rejects(policyCall, /settings changed/);
const policyRequest = await policyApproval;
const executedBeforeChange = executions;
privacy.configure({ enabled: true });
bridge.resolveApprovalFromRenderer(policyRequest.approvalId, true, "once");
await rejectedPolicyCall;
assert.equal(executions, executedBeforeChange, "external approvals must not execute across policy changes");
const staleApproval = nextApproval();
const staleCall = exec(clientC, "local-test");
const staleRequest = await staleApproval;
@@ -0,0 +1,120 @@
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
const bridge = require("./mcpServerBridge.cjs");
const privacy = require("../shared/aiPrivacyService.cjs");
test("managed TCP restores prompt aliases before approval and masks results across reconnects", { timeout: 10000 }, async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "managed-privacy-"));
const clients = [];
const commands = [];
let pendingApproval;
let receiveApproval;
t.after(() => {
for (const client of clients) client.close();
bridge.setMainWindowGetter(() => null);
bridge.cleanup();
privacy.configure({ enabled: false });
fs.rmSync(dir, { recursive: true, force: true });
});
privacy.configure({ enabled: true });
const token = privacy.redact("managed-chat", "192.0.2.13");
bridge.init({
sessions: new Map(),
cliDiscoveryFilePath: path.join(dir, "discovery.json"),
terminalWorkerManager: {
request: async (_method, payload) => {
commands.push(payload);
return { ok: true, stdout: "192.0.2.13", stderr: "", exitCode: 0 };
},
},
});
bridge.setMainWindowGetter(() => ({
isDestroyed: () => false,
webContents: { id: 99, send(channel, payload) {
if (channel === "netcatty:ai:mcp:approval-request") {
pendingApproval = payload;
receiveApproval?.(payload);
}
} },
}));
bridge.setPermissionMode("confirm");
bridge.setCommandBlocklist([]);
bridge.updateSessionMetadata([{ sessionId: "ssh", hostname: "smoke.example", protocol: "ssh", connected: true }], "managed-chat");
const port = await bridge.getOrCreateHost();
const authToken = bridge.buildMcpServerConfig(port, [], "managed-chat").env.find((entry) => entry.name === "NETCATTY_MCP_TOKEN").value;
async function connect() {
const socket = net.createConnection({ host: "127.0.0.1", port });
await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); });
const client = createNdjsonRpcClient({ socket });
clients.push(client);
await client.call("auth/verify", { token: authToken });
return client;
}
const client = await connect();
const approval = new Promise((resolve) => { receiveApproval = resolve; });
const request = client.call("netcatty/exec", { chatSessionId: "managed-chat", sessionId: "ssh", command: `echo ${token}` });
const approved = await approval;
assert.equal(approved.args.command, "echo 192.0.2.13");
bridge.resolveApprovalFromRenderer(pendingApproval.approvalId, true, "once");
assert.equal((await request).stdout, token);
assert.equal(commands.length, 1);
const next = await connect();
const context = await next.call("netcatty/getContext", { chatSessionId: "managed-chat" });
assert.doesNotMatch(JSON.stringify(context), /smoke\.example/);
await assert.rejects(next.call("netcatty/getContext", { chatSessionId: "other-chat" }), /cannot change/);
const callsBeforeApproval = commands.length;
const nextApproval = new Promise((resolve) => { receiveApproval = resolve; });
const staleRequest = client.call("netcatty/exec", { chatSessionId: "managed-chat", sessionId: "ssh", command: `echo ${token}` });
const rejected = assert.rejects(staleRequest, /settings changed|unavailable/);
const staleApproval = await nextApproval;
privacy.configure({ enabled: false });
bridge.resolveApprovalFromRenderer(staleApproval.approvalId, true, "once");
await rejected;
assert.equal(commands.length, callsBeforeApproval, "policy changes during approval must prevent execution");
});
test("privacy-disabled TCP preserves legacy multi-scope and unscoped calls on one socket", { timeout: 10000 }, async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "managed-privacy-off-"));
let client;
t.after(() => {
client?.close();
bridge.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
});
// Production waits for the saved policy, even when it is explicitly off.
privacy.configure({ enabled: false });
bridge.init({
sessions: new Map([
["off-terminal-one", { hostname: "one.example", pty: { write() {} } }],
["off-terminal-two", { hostname: "two.example", pty: { write() {} } }],
]),
cliDiscoveryFilePath: path.join(dir, "discovery.json"),
});
bridge.setPermissionMode("observer");
bridge.updateSessionMetadata([{ sessionId: "off-terminal-one", hostname: "one.example" }], "off-chat-one");
bridge.updateSessionMetadata([{ sessionId: "off-terminal-two", hostname: "two.example" }], "off-chat-two");
const port = await bridge.getOrCreateHost();
const authToken = bridge.buildMcpServerConfig(port, [], "off-chat-one").env
.find((entry) => entry.name === "NETCATTY_MCP_TOKEN").value;
const socket = net.createConnection({ host: "127.0.0.1", port });
await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); });
client = createNdjsonRpcClient({ socket });
await client.call("auth/verify", { token: authToken });
const one = await client.call("netcatty/getContext", { chatSessionId: "off-chat-one" });
const two = await client.call("netcatty/getContext", { chatSessionId: "off-chat-two" });
const unscoped = await client.call("netcatty/getContext", { scopedSessionIds: ["off-terminal-one"] });
assert.equal(one.hosts[0].sessionId, "off-terminal-one");
assert.equal(two.hosts[0].sessionId, "off-terminal-two");
assert.equal(unscoped.hosts[0].sessionId, "off-terminal-one");
assert.equal(one.hosts[0].hostname, "one.example");
assert.equal(two.hosts[0].hostname, "two.example");
});
@@ -0,0 +1,264 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { createSftpHandlerApi } = require("./mcpServerBridge/sftpHandlers.cjs");
const { createAiPrivacyService } = require("../shared/aiPrivacyService.cjs");
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
const operations = [
["handleSftpList", {}],
["handleSftpRead", { path: "/synthetic" }],
["handleSftpWrite", { path: "/synthetic", content: "synthetic" }],
["handleSftpDownload", { remotePath: "/synthetic", localPath: "/synthetic-local" }],
["handleSftpUpload", { remotePath: "/synthetic", localPath: "/synthetic-local" }],
["handleSftpMkdir", { path: "/synthetic" }],
["handleSftpDelete", { path: "/synthetic" }],
["handleSftpRename", { oldPath: "/synthetic", newPath: "/synthetic-new" }],
["handleSftpStat", { path: "/synthetic" }],
["handleSftpChmod", { path: "/synthetic", mode: "600" }],
["handleSftpHome", {}],
];
for (const worker of [false, true]) {
for (const [method, args] of operations) {
test(`${method} blocks a policy change during ${worker ? "worker" : "main"} SFTP open`, async () => {
const privacy = createAiPrivacyService();
privacy.configure({ enabled: true });
privacy.checkSession("chat");
const entered = deferred();
const release = deferred();
const effects = [];
const closed = [];
const open = async () => {
entered.resolve();
await release.promise;
return { sftpId: "synthetic-handle" };
};
const bridge = new Proxy({
openSftpForSession: open,
closeSftp: async (_event, payload) => { closed.push(payload); },
}, {
get: (target, key) => target[key] || (async (_event, payload) => { effects.push({ key, payload }); }),
});
const api = createSftpHandlerApi({
sessions: new Map(worker ? [] : [["ssh", {}]]),
terminalWorkerManager: worker ? {
request: async (channel, payload) => {
if (channel === "netcatty:sftp:openForSession") return open();
if (channel === "netcatty:sftp:close") { closed.push(payload); return; }
effects.push({ channel, payload });
},
} : null,
sftpBridge: bridge,
transferBridge: null,
commandTimeoutMs: 1000,
registerSftpOp: () => () => {},
createTransferId: () => "synthetic-transfer",
reportTransferEvent() {},
});
const pending = api[method]({ sessionId: "ssh", chatSessionId: "chat", ...args }, {
beforeExecute: () => privacy.restore("chat", []),
});
const rejected = assert.rejects(pending, /start a new chat/);
await entered.promise;
privacy.configure({ enabled: false });
release.resolve();
await rejected;
assert.deepEqual(effects, []);
assert.equal(closed.length, 1, "the opened handle must still be released");
});
}
}
for (const method of ["handleSftpDownload", "handleSftpUpload"]) {
for (const changePolicy of [false, true]) {
test(`${method} ${changePolicy ? "blocks changed" : "preserves unchanged"} privacy after main transfer admission`, async () => {
const privacy = createAiPrivacyService();
privacy.configure({ enabled: true });
privacy.checkSession("chat");
const entered = deferred();
const release = deferred();
const effects = [];
const opens = [];
const closed = [];
const admitted = [];
const transferBridge = {
getGlobalTransferConcurrency: () => 1,
async runAdmittedTransfer(event, payload, _progress, runner) {
admitted.push(payload);
entered.resolve();
await release.promise;
return runner
? runner()
: transferBridge.startTransfer(event, { ...payload, skipAdmission: true });
},
startTransfer(event, payload) {
if (payload.skipAdmission !== true) {
return transferBridge.runAdmittedTransfer(event, payload);
}
effects.push(payload);
return Promise.resolve({ transferId: payload.transferId });
},
};
const api = createSftpHandlerApi({
sessions: new Map([["ssh", { hostId: "synthetic-host" }]]),
terminalWorkerManager: null,
sftpBridge: {
openSftpForSession: async (_event, payload) => {
opens.push(payload);
return { sftpId: "synthetic-handle" };
},
closeSftp: async (_event, payload) => { closed.push(payload); },
},
transferBridge,
commandTimeoutMs: 1000,
registerSftpOp: () => () => {},
createTransferId: () => "synthetic-transfer",
reportTransferEvent() {},
});
const pending = api[method]({
sessionId: "ssh", chatSessionId: "chat",
remotePath: "/synthetic-remote", localPath: "/synthetic-local",
}, { beforeExecute: () => privacy.restore("chat", []) });
const rejected = changePolicy ? assert.rejects(pending, /start a new chat/) : null;
await entered.promise;
if (changePolicy) privacy.configure({ enabled: false });
release.resolve();
if (rejected) await rejected;
else assert.equal((await pending).ok, true);
assert.equal(admitted.length, 1, "the transfer must acquire only one admission slot");
const hostField = method === "handleSftpDownload" ? "sourceHostId" : "targetHostId";
assert.equal(admitted[0][hostField], "synthetic-host");
assert.equal(admitted[0].globalConcurrency, 1);
assert.equal(effects.length, changePolicy ? 0 : 1);
assert.equal(opens.length, changePolicy ? 0 : 1);
assert.equal(closed.length, opens.length);
if (!changePolicy) assert.equal(effects[0].skipAdmission, true);
});
}
}
for (const method of ["handleSftpDownload", "handleSftpUpload"]) {
for (const cancelQueued of [false, true]) {
test(`${method} ${cancelQueued ? "cancels" : "completes"} through real admission with chat cleanup`, { timeout: 5000 }, async (t) => {
const realTransfers = require("./transferBridge.cjs");
const { createBackgroundJobApi } = require("./mcpServerBridge/backgroundJobs.cjs");
const previousLimit = realTransfers.getGlobalTransferConcurrency();
realTransfers.setGlobalTransferConcurrency(1);
const hostId = `synthetic-${method}-${cancelQueued}`;
const transferId = `${hostId}-transfer`;
const releaseBlocker = deferred();
const queued = deferred();
const blocker = realTransfers.runAdmittedTransfer(
{ sender: { send() {} } },
{ transferId: `${hostId}-blocker`, sourceHostId: hostId },
undefined,
() => releaseBlocker.promise,
);
const privacy = createAiPrivacyService();
privacy.configure({ enabled: true });
privacy.checkSession("chat");
const registryContext = {
activeSessionSftpOps: new Map(), closingTerminalSessions: new Map(), activeSftpOpSeq: 0,
};
const registry = createBackgroundJobApi(registryContext);
const effects = [];
const opens = [];
const closed = [];
const events = [];
let admissionCount = 0;
let registrationCount = 0;
let releaseCount = 0;
let cancelCount = 0;
let pending;
t.after(async () => {
releaseBlocker.resolve();
await blocker;
await pending;
realTransfers.setGlobalTransferConcurrency(previousLimit);
});
const api = createSftpHandlerApi({
sessions: new Map([["ssh", { hostId }]]),
terminalWorkerManager: null,
sftpBridge: {
openSftpForSession: async () => { opens.push(true); return { sftpId: "synthetic-handle" }; },
closeSftp: async () => { closed.push(true); },
},
transferBridge: {
getGlobalTransferConcurrency: () => 1,
runAdmittedTransfer: (...args) => {
admissionCount++;
return realTransfers.runAdmittedTransfer(...args);
},
cancelTransfer: (...args) => {
cancelCount++;
return realTransfers.cancelTransfer(...args);
},
startTransfer: async (_event, payload) => {
assert.equal(payload.skipAdmission, true);
effects.push(payload);
return { transferId };
},
},
commandTimeoutMs: 1000,
registerSftpOp: (...args) => {
registrationCount++;
const unregister = registry.registerSftpOp(...args);
return () => { releaseCount++; unregister(); };
},
createTransferId: () => transferId,
reportTransferEvent: (event) => {
events.push(event);
if (event.type === "queued" && event.transferId === transferId) queued.resolve();
},
});
let settled = false;
pending = api[method]({
sessionId: "ssh", chatSessionId: "chat",
remotePath: "/synthetic-remote", localPath: "/synthetic-local",
}, { beforeExecute: () => privacy.restore("chat", []) }).then((result) => {
settled = true;
return result;
});
await queued.promise;
assert.equal(opens.length, 0);
if (cancelQueued) {
await registry.cancelSftpOpsForSession("chat");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, true, "queued cancellation must settle before the occupied slot is released");
assert.deepEqual(await pending, { ok: false, cancelled: true, transferId });
assert.equal(cancelCount, 1);
assert.equal(events.filter((event) => event.type === "cancelled").length, 1);
} else {
releaseBlocker.resolve();
await blocker;
assert.equal((await pending).ok, true);
assert.equal(cancelCount, 0);
assert.equal(events.filter((event) => event.type === "completed").length, 1);
}
releaseBlocker.resolve();
await blocker;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(admissionCount, 1);
assert.equal(effects.length, cancelQueued ? 0 : 1);
assert.equal(opens.length, effects.length);
assert.equal(closed.length, opens.length);
assert.equal(registryContext.activeSessionSftpOps.size, 0);
assert.equal(registrationCount, cancelQueued ? 1 : 2);
assert.equal(releaseCount, registrationCount, "each cancellation registration is released exactly once");
assert.equal(realTransfers._getPendingCancelCountForTests(), 0);
assert.equal(await realTransfers.runAdmittedTransfer(
{ sender: { send() {} } },
{ transferId: `${hostId}-next`, sourceHostId: hostId },
undefined,
async () => "slot released",
), "slot released");
});
}
}
@@ -198,7 +198,8 @@ function createCapabilityRpcDispatcher(deps) {
const hostOpenGeneration = capability.id === "vault.host.open"
? deps.captureHostOpenScope?.(params?.chatSessionId)
: null;
const result = await handler(params);
approvalContext.beforeExecute?.();
const result = await handler(params, approvalContext);
if (capability.id === "vault.host.open" && result?.ok !== false && result?.sessionId) {
await deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration, result);
}
@@ -445,3 +445,44 @@ test("implemented vault capabilities do not return CAPABILITY_NOT_IMPLEMENTED",
const result = await dispatch("vault/host/get", { hostId: "host-1" });
assert.notEqual(result.code, "CAPABILITY_NOT_IMPLEMENTED");
});
test("managed privacy is revalidated after capability approval and before writes", async () => {
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
const { createManagedPrivacyBoundary } = require("../../shared/managedPrivacyBoundary.cjs");
const privacy = createAiPrivacyService();
privacy.configure({ enabled: true });
const token = privacy.redact("approval-chat", "192.0.2.14");
let invoked = false;
const dispatch = createManagedPrivacyBoundary(createTestDispatcher({
evaluatePermissionWithGrants: () => ({ allowed: true, requiresApproval: true }),
requestApprovalFromRenderer: async (_tool, args) => {
assert.equal(args.notes, "192.0.2.14");
privacy.configure({ enabled: false });
return true;
},
invokeVaultAgent: async () => { invoked = true; return { ok: true }; },
}), privacy);
await assert.rejects(dispatch("public/vault/hostNotes/set", {
chatSessionId: "approval-chat", hostId: "host-1", notes: token,
}), /settings changed|unavailable/);
assert.equal(invoked, false);
});
test("managed session close revalidates privacy after asynchronous close preparation", async () => {
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
const { createManagedPrivacyBoundary } = require("../../shared/managedPrivacyBoundary.cjs");
const privacy = createAiPrivacyService();
privacy.configure({ enabled: true });
let invoked = false;
let outcome;
const dispatch = createManagedPrivacyBoundary(createTestDispatcher({
beforeSessionClose: async () => { privacy.configure({ enabled: false }); },
afterSessionClose: async (_params, result) => { outcome = result; },
invokeVaultAgent: async () => { invoked = true; return { ok: true }; },
}), privacy);
await assert.rejects(dispatch("public/session/close", {
chatSessionId: "closing-chat", sessionId: "session-1",
}), /settings changed|unavailable/);
assert.equal(invoked, false);
assert.equal(outcome.closed, false);
});
@@ -73,7 +73,7 @@ function createExecHandlerApi(ctx) {
};
}
function handleExec(params) {
function handleExec(params, executionContext = {}) {
const resolved = resolveExecContext(params);
if (!resolved.ok) return resolved;
const {
@@ -103,6 +103,7 @@ function createExecHandlerApi(ctx) {
const runExecution = (factory) => {
try {
executionContext.beforeExecute?.();
return Promise.resolve(factory()).finally(() => {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
@@ -138,6 +139,7 @@ function createExecHandlerApi(ctx) {
chatSessionId,
});
if (!probed.ok) return probed;
executionContext.beforeExecute?.();
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
if (safety.blocked) {
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
@@ -182,6 +184,7 @@ function createExecHandlerApi(ctx) {
chatSessionId,
});
if (!probed.ok) return probed;
executionContext.beforeExecute?.();
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
if (safety.blocked) {
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
@@ -217,7 +220,7 @@ function createExecHandlerApi(ctx) {
return { ok: false, error: "Session does not support command execution" };
}
function handleJobStart(params) {
function handleJobStart(params, executionContext = {}) {
const resolved = resolveExecContext(params);
if (!resolved.ok) return resolved;
const {
@@ -313,6 +316,7 @@ function createExecHandlerApi(ctx) {
let handle;
try {
executionContext.beforeExecute?.();
handle = startPtyJob(ptyStream, command, {
onInterrupt: () => clearSessionFlowState(session),
// Intentionally do NOT register in activePtyExecs: terminal_start jobs
@@ -100,6 +100,7 @@ function createSftpHandlerApi(ctx) {
try {
const manager = getWorkerManager();
options.beforeExecute?.();
pendingOpenPromise = manager.request("netcatty:sftp:openForSession", {
sessionId: params.sessionId,
encodingStateKey,
@@ -122,6 +123,7 @@ function createSftpHandlerApi(ctx) {
const workerPayload = options.buildWorkerPayload
? options.buildWorkerPayload(workerParams, sftpId)
: { ...workerParams, sftpId, timeoutMs };
options.beforeExecute?.();
const value = await requestWorkerSftp(workerChannel, workerPayload, { timeoutMs, operationName });
if (cancellationError) throw cancellationError;
return value;
@@ -202,6 +204,7 @@ function createSftpHandlerApi(ctx) {
}, timeoutMs);
}
options.beforeExecute?.();
const opened = await sftpBridge.openSftpForSession(null, {
sessionId: params.sessionId,
encodingStateKey,
@@ -223,7 +226,10 @@ function createSftpHandlerApi(ctx) {
abortSignal: abortController.signal,
timeoutMs,
};
const value = await Promise.resolve().then(() => action(payload));
const value = await Promise.resolve().then(() => {
options.beforeExecute?.();
return action(payload);
});
if (timeoutError) {
throw timeoutError;
}
@@ -254,37 +260,68 @@ function createSftpHandlerApi(ctx) {
}
}
async function handleSftpList(params) {
async function runAdmittedSessionTransfer(params, event, payload, runner) {
let queued = false;
let cancelled = false;
let registered = true;
const cancelledResult = () => ({ transferId: payload.transferId, error: "Transfer cancelled", cancelled: true });
const unregister = registerSftpOp(params?.chatSessionId, params?.sessionId, () => {
cancelled = true;
if (queued) return transferBridge.cancelTransfer?.(null, { transferId: payload.transferId });
});
const releaseRegistration = () => {
if (!registered) return;
registered = false;
unregister();
};
try {
// Registration may synchronously cancel an already-closing terminal.
if (cancelled) return cancelledResult();
queued = true;
return await transferBridge.runAdmittedTransfer(event, payload, undefined, async () => {
queued = false;
releaseRegistration();
if (cancelled) return cancelledResult();
// The runner registers handle cleanup synchronously before its first
// await, so admission and active-operation cancellation never overlap.
return runner();
});
} finally {
releaseRegistration();
}
}
async function handleSftpList(params, executionContext = {}) {
const entries = await withSessionBackedSftp(
params,
(payload) => sftpBridge.listSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP list", workerChannel: "netcatty:sftp:list" },
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP list", workerChannel: "netcatty:sftp:list" },
);
return { ok: true, entries };
}
async function handleSftpRead(params) {
async function handleSftpRead(params, executionContext = {}) {
if (!params?.path) throw new Error("path is required");
const content = await withSessionBackedSftp(
params,
(payload) => sftpBridge.readSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP read", workerChannel: "netcatty:sftp:read" },
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP read", workerChannel: "netcatty:sftp:read" },
);
return { ok: true, path: params.path, content };
}
async function handleSftpWrite(params) {
async function handleSftpWrite(params, executionContext = {}) {
if (!params?.path) throw new Error("path is required");
if (typeof params?.content !== "string") throw new Error("content is required");
await withSessionBackedSftp(
params,
(payload) => sftpBridge.writeSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP write", workerChannel: "netcatty:sftp:write" },
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP write", workerChannel: "netcatty:sftp:write" },
);
return { ok: true, path: params.path };
}
async function handleSftpDownload(params) {
async function handleSftpDownload(params, executionContext = {}) {
if (!params?.remotePath || !params?.localPath) {
throw new Error("remotePath and localPath are required");
}
@@ -297,6 +334,7 @@ function createSftpHandlerApi(ctx) {
sourceHostId,
});
try {
let cancellationReported = false;
const sender = {
send(channel, payload) {
if (channel === "netcatty:transfer:progress") {
@@ -308,13 +346,14 @@ function createSftpHandlerApi(ctx) {
} else if (channel === "netcatty:transfer:paused") {
reportTransferEvent({ type: "paused", ...payload });
} else if (channel === "netcatty:transfer:cancelled") {
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
if (!cancellationReported) reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
cancellationReported = true;
}
},
};
const useOuterAdmission = !!(
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
);
// Admit before opening the handle so the execution guard runs after
// queue waits for main-process transfers as well as worker transfers.
const useOuterAdmission = typeof transferBridge?.runAdmittedTransfer === "function";
const runDownload = (skipAdmission) => withSessionBackedSftp(
params,
(payload) => transferBridge
@@ -333,6 +372,7 @@ function createSftpHandlerApi(ctx) {
})
: sftpBridge.downloadSftpToLocal(null, payload),
{
...executionContext,
timeoutMs: commandTimeoutMs,
operationName: "SFTP download",
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:downloadToLocal",
@@ -351,16 +391,16 @@ function createSftpHandlerApi(ctx) {
);
const result = await (
useOuterAdmission
? transferBridge.runAdmittedTransfer(
? runAdmittedSessionTransfer(
params,
{ sender },
{ transferId, sourceHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
undefined,
() => runDownload(true),
)
: runDownload(false)
);
if (result?.cancelled || result?.error === "Transfer cancelled") {
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
if (!cancellationReported) reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
return { ok: false, cancelled: true, transferId };
}
if (result?.error) throw new Error(result.error);
@@ -372,7 +412,7 @@ function createSftpHandlerApi(ctx) {
}
}
async function handleSftpUpload(params) {
async function handleSftpUpload(params, executionContext = {}) {
if (!params?.remotePath || !params?.localPath) {
throw new Error("remotePath and localPath are required");
}
@@ -385,6 +425,7 @@ function createSftpHandlerApi(ctx) {
targetHostId,
});
try {
let cancellationReported = false;
const sender = {
send(channel, payload) {
if (channel === "netcatty:transfer:progress") {
@@ -396,13 +437,12 @@ function createSftpHandlerApi(ctx) {
} else if (channel === "netcatty:transfer:paused") {
reportTransferEvent({ type: "paused", ...payload });
} else if (channel === "netcatty:transfer:cancelled") {
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
if (!cancellationReported) reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
cancellationReported = true;
}
},
};
const useOuterAdmissionUpload = !!(
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
);
const useOuterAdmissionUpload = typeof transferBridge?.runAdmittedTransfer === "function";
const runUpload = (skipAdmission) => withSessionBackedSftp(
params,
(payload) => transferBridge
@@ -421,6 +461,7 @@ function createSftpHandlerApi(ctx) {
})
: sftpBridge.uploadLocalToSftp(null, payload),
{
...executionContext,
timeoutMs: commandTimeoutMs,
operationName: "SFTP upload",
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:uploadLocal",
@@ -439,16 +480,16 @@ function createSftpHandlerApi(ctx) {
);
const result = await (
useOuterAdmissionUpload
? transferBridge.runAdmittedTransfer(
? runAdmittedSessionTransfer(
params,
{ sender },
{ transferId, targetHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
undefined,
() => runUpload(true),
)
: runUpload(false)
);
if (result?.cancelled || result?.error === "Transfer cancelled") {
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
if (!cancellationReported) reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
return { ok: false, cancelled: true, transferId };
}
if (result?.error) throw new Error(result.error);
@@ -460,63 +501,63 @@ function createSftpHandlerApi(ctx) {
}
}
async function handleSftpMkdir(params) {
async function handleSftpMkdir(params, executionContext = {}) {
if (!params?.path) throw new Error("path is required");
await withSessionBackedSftp(
params,
(payload) => sftpBridge.mkdirSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP mkdir", workerChannel: "netcatty:sftp:mkdir" },
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP mkdir", workerChannel: "netcatty:sftp:mkdir" },
);
return { ok: true, path: params.path };
}
async function handleSftpDelete(params) {
async function handleSftpDelete(params, executionContext = {}) {
if (!params?.path) throw new Error("path is required");
await withSessionBackedSftp(
params,
(payload) => sftpBridge.deleteSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP delete", workerChannel: "netcatty:sftp:delete" },
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP delete", workerChannel: "netcatty:sftp:delete" },
);
return { ok: true, path: params.path };
}
async function handleSftpRename(params) {
async function handleSftpRename(params, executionContext = {}) {
if (!params?.oldPath || !params?.newPath) {
throw new Error("oldPath and newPath are required");
}
await withSessionBackedSftp(
params,
(payload) => sftpBridge.renameSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP rename", workerChannel: "netcatty:sftp:rename" },
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP rename", workerChannel: "netcatty:sftp:rename" },
);
return { ok: true, oldPath: params.oldPath, newPath: params.newPath };
}
async function handleSftpStat(params) {
async function handleSftpStat(params, executionContext = {}) {
if (!params?.path) throw new Error("path is required");
const stat = await withSessionBackedSftp(
params,
(payload) => sftpBridge.statSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP stat", workerChannel: "netcatty:sftp:stat" },
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP stat", workerChannel: "netcatty:sftp:stat" },
);
return { ok: true, stat };
}
async function handleSftpChmod(params) {
async function handleSftpChmod(params, executionContext = {}) {
if (!params?.path || !params?.mode) throw new Error("path and mode are required");
await withSessionBackedSftp(
params,
(payload) => sftpBridge.chmodSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP chmod", workerChannel: "netcatty:sftp:chmod" },
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP chmod", workerChannel: "netcatty:sftp:chmod" },
);
return { ok: true, path: params.path, mode: params.mode };
}
async function handleSftpHome(params) {
async function handleSftpHome(params, executionContext = {}) {
const result = await withSessionBackedSftp(
params,
(payload) => sftpBridge.getSftpHomeDir(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP home", workerChannel: "netcatty:sftp:homeDir" },
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP home", workerChannel: "netcatty:sftp:homeDir" },
);
if (!result?.success) {
throw new Error(result?.error || "Could not determine home directory");
@@ -1234,6 +1234,32 @@ function createTerminalWorkerManager(options = {}) {
function handleMessage(eventOrMessage) {
const message = unwrapMessageEvent(eventOrMessage);
if (!message || typeof message !== "object") return;
if (message.kind === "validate-execution") {
const entry = pending.get(message.requestId);
let ok = false;
try {
if (typeof entry?.beforeExecute !== "function") throw new Error("Missing execution guard");
// These guards are synchronous main-owned policy snapshots. No
// renderer callback or function is serialized to the worker.
const result = entry.beforeExecute();
if (result && typeof result.then === "function") throw new Error("Invalid execution guard");
ok = true;
} catch {
// Return no command content or policy internals to a stale worker call.
}
const worker = child;
try {
worker?.postMessage({
kind: "execution-validation",
requestId: message.requestId,
validationId: message.validationId,
ok,
});
} catch (error) {
retireWorkerAfterIpcFailure(worker, error);
}
return;
}
if (message.kind === "job-diagnostic") {
if (isJobDiagEnabled() && typeof message.line === "string"
&& message.line.startsWith("[Netcatty JobDiag] ")) {
@@ -1824,6 +1850,7 @@ function createTerminalWorkerManager(options = {}) {
requestedSessionId,
bootEpoch: normalizeBootEpoch(payload?.bootEpoch),
webContentsId: optionsForRequest.webContentsId,
beforeExecute: optionsForRequest.beforeExecute,
opensOutputSession: channel === "netcatty:start"
|| channel === "netcatty:local:reconnect"
|| channel === "netcatty:external:start"
@@ -1861,6 +1888,8 @@ function createTerminalWorkerManager(options = {}) {
channel,
payload,
webContentsId: optionsForRequest.webContentsId,
...(typeof optionsForRequest.beforeExecute === "function"
? { requiresExecutionValidation: true } : {}),
});
} catch (error) {
retireWorkerAfterIpcFailure(worker, error);
@@ -62,6 +62,35 @@ class LinkedRuntimeChild extends FakeChild {
}
}
test("worker execution validation uses the pending main guard without serializing it", async (t) => {
const child = new LinkedRuntimeChild();
let effects = 0;
let checks = 0;
child.connectRuntime(ipcMain => {
ipcMain.handle("netcatty:ai:exec", async event => {
await event.beforeExecute();
effects++;
return { ok: true };
});
});
const manager = createTerminalWorkerManager({ utilityProcess: { fork: () => child } });
t.after(() => manager.stop());
assert.deepEqual(await manager.request("netcatty:ai:exec", {}, {
beforeExecute: () => { checks++; },
}), { ok: true });
assert.equal(effects, 1);
assert.equal(checks, 1);
const request = child.messages.find(message => message.kind === "request");
assert.equal(request.requiresExecutionValidation, true);
assert.equal(Object.hasOwn(request, "beforeExecute"), false);
await assert.rejects(manager.request("netcatty:ai:exec", {}, {
beforeExecute: () => { throw new Error("synthetic-private-policy-detail"); },
}), /privacy session is unavailable/);
assert.equal(effects, 1);
child.emit("message", { kind: "validate-execution", requestId: "expired", validationId: "late" });
assert.equal(child.messages.at(-1).ok, false);
});
function emitOutputPortReady(child, sessionId) {
const openMessage = [...child.messages].reverse().find((message) => (
message.kind === "output-port" && message.sessionId === sessionId
@@ -0,0 +1,122 @@
"use strict";
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const test = require("node:test");
const { createTerminalWorkerManager } = require("./terminalWorkerManager.cjs");
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
const { registerWorkerAiExecHandlers } = require("../terminalWorker/aiExec.cjs");
const { registerCattyExecHandlers } = require("./aiBridge/cattyExecHandlers.cjs");
const { createAiPrivacyService } = require("../shared/aiPrivacyService.cjs");
const { createManagedPrivacyBoundary } = require("../shared/managedPrivacyBoundary.cjs");
const { PROBE_OUTPUT_MARKER } = require("./ai/sessionShellKind.cjs");
const { isolateCliDiscoveryFile } = require("./cliDiscoveryTestIsolation.cjs");
isolateCliDiscoveryFile();
const bridge = require("./mcpServerBridge.cjs");
class LinkedWorker extends EventEmitter {
constructor(sessions) {
super();
this.messages = [];
let receive;
const runtime = createTerminalWorkerRuntime({
parentPort: {
on: (_channel, listener) => { receive = listener; },
postMessage: message => queueMicrotask(() => this.emit("message", message)),
},
registerBridges: ipcMain => registerWorkerAiExecHandlers(ipcMain, { sessions }),
});
runtime.start();
this.receive = message => receive(message);
}
postMessage(message) {
this.messages.push(message);
queueMicrotask(() => this.receive(message));
}
kill() {}
}
for (const [source, transport] of [
["catty", "pty"],
["catty", "channel"],
["managed-exec", "pty"],
["managed-exec", "channel"],
["managed-job", "pty"],
]) {
test(`${source} rejects policy invalidation during worker ${transport} shell probe`, async (t) => {
const service = createAiPrivacyService();
service.configure({ enabled: true });
const chatSessionId = `${source}-${transport}`;
const command = "echo 192.0.2.41";
const alias = service.redact(chatSessionId, command);
const writes = [];
let probeEntered;
let releaseProbe;
const entered = new Promise(resolve => { probeEntered = resolve; });
const probe = new Promise(resolve => { releaseProbe = resolve; });
const session = {
protocol: "ssh",
_shellKindExecProbe: async () => {
probeEntered();
await probe;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
},
};
if (transport === "pty") {
session.stream = new EventEmitter();
session.stream.write = value => { writes.push(value); };
} else {
session.conn = { exec: value => { writes.push(value); } };
}
const worker = new LinkedWorker(new Map([["session", session]]));
const manager = createTerminalWorkerManager({ utilityProcess: { fork: () => worker } });
t.after(() => { bridge.cleanup(); manager.stop(); });
bridge.init({ sessions: new Map(), terminalWorkerManager: manager, electronModule: null });
bridge.setPermissionMode("auto");
bridge.setCommandBlocklist([]);
bridge.updateSessionMetadata([{
sessionId: "session", protocol: "ssh", connected: true,
}], chatSessionId);
let pending;
if (source === "catty") {
const handlers = new Map();
registerCattyExecHandlers({
ipcMain: { handle: (name, handler) => handlers.set(name, handler) },
validateSender: () => true,
aiPrivacyService: service,
sessions: new Map(),
terminalWorkerManager: manager,
mcpServerBridge: bridge,
});
pending = handlers.get("netcatty:ai:exec")({ sender: { id: 7 } }, {
sessionId: "session", command, chatSessionId,
});
} else {
const dispatch = createManagedPrivacyBoundary(bridge.dispatchBuiltinRpc, service);
pending = dispatch(source === "managed-job" ? "netcatty/jobStart" : "netcatty/exec", {
sessionId: "session", command: alias, chatSessionId,
});
}
// Attach rejection handling before retiring the managed conversation.
const result = Promise.resolve(pending).catch(error => ({ ok: false, error: error.message }));
await entered;
assert.equal(writes.length, 0);
service.configure({ enabled: false });
releaseProbe();
const rejected = await result;
assert.equal(rejected.ok, false);
assert.match(rejected.error, /privacy session is unavailable/);
assert.deepEqual(writes, [], "neither PTY nor SSH command may run after policy retirement");
assert.ok(worker.messages.some(message => message.requiresExecutionValidation === true));
assert.ok(worker.messages.some(message => message.kind === "execution-validation" && message.ok === false));
if (source === "managed-job") {
const retry = await manager.request("netcatty:ai:jobStart", {
sessionId: "session", command, chatSessionId,
}, { beforeExecute: () => { throw new Error("retired"); } });
assert.match(retry.error, /privacy session is unavailable/, "failed validation releases the job reservation");
}
});
}
@@ -26,6 +26,8 @@ test("MCP environment resource serializes terminal tool hints from Netcatty cont
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
// Mirror renderer startup: managed requests wait until the saved policy is known.
require("../../shared/aiPrivacyService.cjs").configure({ enabled: false });
const bridge = loadFreshBridge();
const chatSessionId = `chat-resource-${Date.now()}`;
let client = null;
@@ -19,6 +19,7 @@ function createSessionService(ctx = {}) {
let closed = false;
try {
await beforeClose?.(params);
options.beforeExecute?.();
result = await invokeSessionAgent("session.close", { sessionId: params.sessionId });
if (result?.ok !== false) {
await onClosed?.(params.sessionId);
@@ -35,7 +36,9 @@ function createSessionService(ctx = {}) {
}
return {
close: (params = {}) => close(params),
close: (params = {}, executionContext = {}) => close(params, {
beforeExecute: executionContext.beforeExecute,
}),
closeTracked: (params = {}) => close(params, { skipValidation: true }),
};
}
@@ -53,3 +53,22 @@ test("already-missing sessions are distinguished from retryable close failures",
assert.equal(outcome.closed, false);
assert.equal(outcome.notFound, true);
});
test("close rechecks its execution guard after cleanup and preserves failed-close lifecycle", async () => {
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
const privacy = createAiPrivacyService();
privacy.checkSession("chat");
let invokeCount = 0;
let outcome;
const service = createSessionService({
beforeClose: async () => { privacy.configure({ enabled: true }); },
invokeSessionAgent: async () => { invokeCount++; return { ok: true }; },
afterClose: async (_params, value) => { outcome = value; },
});
await assert.rejects(service.close({ sessionId: "session-1" }, {
beforeExecute: () => privacy.restore("chat", []),
}), /start a new chat/);
assert.equal(invokeCount, 0);
assert.equal(outcome.closed, false);
assert.equal(outcome.notFound, false);
});
+3
View File
@@ -10,6 +10,7 @@ function buildExternalDiscoveryPayload({
pid,
permissionMode,
chatSessionId,
redaction,
}) {
return {
version: 1,
@@ -19,6 +20,7 @@ function buildExternalDiscoveryPayload({
pid,
permissionMode: permissionMode || "confirm",
chatSessionId: chatSessionId || "__external_mcp__",
...(redaction ? { redaction } : {}),
updatedAt: new Date().toISOString(),
};
}
@@ -55,6 +57,7 @@ function readExternalDiscovery(filePath) {
? parsed.chatSessionId
: "__external_mcp__",
pid: parsed.pid ?? null,
redaction: parsed.redaction,
};
}
@@ -0,0 +1,75 @@
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
test("stdio External MCP preserves a read/write round trip without exposing original values", async () => {
const calls = [];
const files = new Map([["/first", "address=192.0.2.10\npassword=synthetic-only-secret"], ["/second", "198.51.100.7"]]);
const sockets = new Set();
const bridge = net.createServer(socket => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
socket.setEncoding("utf8");
socket.on("data", chunk => {
buffer += chunk;
while (buffer.includes("\n")) {
const end = buffer.indexOf("\n");
const { id, method, params } = JSON.parse(buffer.slice(0, end));
buffer = buffer.slice(end + 1);
calls.push({ method, params });
let result = { ok: true };
if (method === "netcatty/sftp/read") result.content = files.get(params.path);
if (method === "netcatty/sftp/write") files.set(params.path, params.content);
if (method === "netcatty/getContext") result.hosts = [{ hostname: "private.example", address: "192.0.2.10" }];
if (method === "netcatty/exec") result = { ok: true, stdout: params.command, exitCode: 0 };
if (method.includes("readAttachment")) result = { ok: true, base64Data: "YmluYXJ5", mediaType: "image/png" };
socket.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
}
});
});
await new Promise(resolve => bridge.listen(0, "127.0.0.1", resolve));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-privacy-"));
const discovery = path.join(tmp, "discovery.json");
fs.writeFileSync(discovery, JSON.stringify({ port: bridge.address().port, token: "synthetic-test-auth", redaction: { enabled: true } }));
const client = new Client({ name: "privacy-test", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(__dirname, "netcatty-external-mcp-server.cjs")],
env: { ...process.env, NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: discovery },
stderr: "pipe",
});
try {
await client.connect(transport);
const read = async file => JSON.parse((await client.callTool({ name: "sftp_read_file", arguments: { sessionId: "s", path: file } })).content[0].text).content;
const first = await read("/first");
const second = await read("/second");
assert.doesNotMatch(first, /192\.0\.2\.10|synthetic-only-secret/);
assert.doesNotMatch(second, /198\.51\.100\.7/);
assert.notEqual(first, second);
assert.equal(await read("/first"), first);
const write = await client.callTool({ name: "sftp_write_file", arguments: { sessionId: "s", path: "/copy", content: first } });
assert.notEqual(write.isError, true);
assert.equal(files.get("/copy"), files.get("/first"));
const resource = await client.readResource({ uri: "netcatty://context" });
assert.doesNotMatch(JSON.stringify(resource), /192\.0\.2\.10|private\.example/);
const exec = await client.callTool({ name: "terminal_execute", arguments: { sessionId: "s", command: `echo ${second}` } });
assert.equal(calls.findLast(call => call.method === "netcatty/exec").params.command, "echo 198.51.100.7");
assert.doesNotMatch(JSON.stringify(exec), /198\.51\.100\.7/);
const before = calls.length;
const invalid = await client.callTool({ name: "terminal_execute", arguments: { sessionId: "s", command: "echo [NC_000000000000_IP_1]" } });
assert.equal(invalid.isError, true);
assert.equal(calls.length, before);
} finally {
await client.close();
for (const socket of sockets) socket.destroy();
await new Promise(resolve => bridge.close(resolve));
fs.rmSync(tmp, { recursive: true, force: true });
}
});
@@ -0,0 +1,52 @@
"use strict";
const { createRedactionSession, normalizeRedactionConfig } = require("./externalMcpRedaction.cjs");
const RECONNECT_MESSAGE = "External MCP privacy settings changed. Reconnect the MCP client before continuing.";
/** One boundary belongs to one stdio process or HTTP MCP session, never a chat scope. */
function createPrivacyBoundary(rpcCall, getConfig) {
let fingerprint;
let session;
function checkConfig() {
let config;
try {
config = normalizeRedactionConfig(getConfig());
} catch {
throw new Error("External MCP privacy configuration is unavailable or invalid. Check Settings and reconnect.");
}
const current = JSON.stringify(config);
if (fingerprint !== undefined && current !== fingerprint) {
throw new Error(RECONNECT_MESSAGE);
}
if (fingerprint === undefined) {
fingerprint = current;
session = config.enabled ? createRedactionSession(config) : null;
}
return session;
}
return async (method, params) => {
const protection = checkConfig();
// Restoration precedes the main-process validation, command policy and approval UI.
const restored = protection ? protection.restore(params) : params;
let result;
try {
result = await rpcCall(method, restored);
} catch (error) {
checkConfig();
if (!protection) throw error;
throw new Error(protection.redact(String(error?.message || "Netcatty tool failed.")));
}
checkConfig();
if (protection && method === "netcatty/readAttachment" && typeof result?.text === "string") {
// The public attachment tool sends text instead of its encoded duplicate.
const { base64Data: _encoded, ...textPayload } = result;
result = textPayload;
}
return protection ? protection.redact(result, { preserveRootKeys: true }) : result;
};
}
module.exports = { createPrivacyBoundary, RECONNECT_MESSAGE };
@@ -0,0 +1,90 @@
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { createPrivacyBoundary } = require("./externalMcpPrivacyBoundary.cjs");
const enabled = { enabled: true };
test("restores before dispatch and masks structured results and errors", async () => {
const calls = [];
const boundary = createPrivacyBoundary(async (method, args) => {
calls.push(args);
if (method === "error") throw new Error("Connection to 192.0.2.10 failed");
return { ok: true, text: "server=192.0.2.10", nested: { password: 'fake-quote"\\secret' } };
}, () => enabled);
const result = await boundary("read", {});
assert.doesNotMatch(JSON.stringify(result), /192\.0\.2\.10|fake-quote/);
await boundary("write", { command: result.text, content: result.nested.password });
assert.equal(calls[1].command, "server=192.0.2.10");
assert.equal(calls[1].content, 'fake-quote"\\secret');
await assert.rejects(boundary("error", {}), error => !error.message.includes("192.0.2.10"));
});
test("unknown and cross-session tokens never reach dispatch", async () => {
let calls = 0;
const one = createPrivacyBoundary(async () => ({ text: "192.0.2.10" }), () => enabled);
const two = createPrivacyBoundary(async () => { calls++; return {}; }, () => enabled);
const result = await one("read", {});
await assert.rejects(two("write", { content: result.text }));
assert.equal(calls, 0);
});
test("configuration changes require reconnect, including disabling", async () => {
let config = enabled;
let calls = 0;
const boundary = createPrivacyBoundary(async () => { calls++; return {}; }, () => config);
await boundary("read", {});
config = { enabled: false };
await assert.rejects(boundary("read", {}), /Reconnect/);
assert.equal(calls, 1);
});
test("configuration changes while an operation runs cannot leak its result", async () => {
let config = enabled;
const boundary = createPrivacyBoundary(async () => {
config = { enabled: false };
return { text: "192.0.2.10" };
}, () => config);
await assert.rejects(boundary("read", {}), /Reconnect/);
});
test("unavailable/invalid policy fails closed before dispatch without echoing rules", async () => {
let calls = 0;
for (const getConfig of [() => { throw new Error("sensitive-path"); }, () => ({ patterns: ["["] })]) {
const boundary = createPrivacyBoundary(async () => { calls++; }, getConfig);
await assert.rejects(boundary("read", {}), error => /configuration/.test(error.message) && !error.message.includes("sensitive-path"));
}
assert.equal(calls, 0);
});
test("disabled mode remains byte-for-byte compatible", async () => {
const source = { text: "192.0.2.10", base64Data: "YmluYXJ5" };
let received;
const boundary = createPrivacyBoundary(async (_, params) => { received = params; return source; }, () => undefined);
const args = { content: "unchanged" };
assert.equal(await boundary("read", args), source);
assert.equal(received, args);
});
test("text attachments omit the encoded duplicate but binary-only attachments fail closed", async () => {
let binary = false;
const boundary = createPrivacyBoundary(async () => ({
ok: true, filePath: undefined, base64Data: "MTkyLjAuMi4xMA==",
...(binary ? {} : { text: "192.0.2.10" }),
}), () => enabled);
const result = await boundary("netcatty/readAttachment", { filename: "synthetic.txt", filePath: undefined });
assert.equal(Object.hasOwn(result, "base64Data"), false);
assert.doesNotMatch(result.text, /192\.0\.2\.10/);
binary = true;
await assert.rejects(boundary("netcatty/readAttachment", { filename: "synthetic.png" }));
});
test("RPC control keys survive a matching secret value so terminal formatting retains output", async () => {
const { formatTerminalExecuteMcpResponse } = require("../capabilities/codegen/mcpToolRegistry.cjs");
const boundary = createPrivacyBoundary(async () => ({ ok: true, stdout: "password=stdout\nimportant output", exitCode: 0 }), () => enabled);
const response = formatTerminalExecuteMcpResponse(await boundary("netcatty/exec", {}));
assert.match(response.content[0].text, /important output/);
assert.doesNotMatch(response.content[0].text, /password=stdout/);
});
+411
View File
@@ -0,0 +1,411 @@
"use strict";
const { randomBytes } = require("node:crypto");
const { BlockList, isIP } = require("node:net");
const { RE2JS } = require("re2js");
const CONFIG_ERROR = "Invalid External MCP redaction configuration";
const VALUE_ERROR = "External MCP redaction could not safely process this value";
const TOKEN_ERROR = "Unknown External MCP redaction token; reconnect the client";
const LIMIT_ERROR = "External MCP redaction session limit reached; reconnect the client";
const MAX_ENTRIES = 10000;
const MAX_BYTES = 8 * 1024 * 1024;
const MAX_NODES = 100000;
const MAX_DEPTH = 100;
const CONFIG_KEYS = new Set(["enabled", "redactPrivateIps", "cidrs", "patterns"]);
const RESERVED_TOKEN = /\[NC_[^\]\r\n]*(?:\]|(?=[\r\n]|$))/g;
function plainObject(value) {
return value !== null && typeof value === "object"
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
}
function parseCidr(value) {
if (typeof value !== "string") throw new Error(CONFIG_ERROR);
const match = /^([^/%\s]+)\/(0|[1-9]\d*)$/.exec(value);
const version = match && isIP(match[1]);
const prefix = match && Number(match[2]);
if (!version || prefix > (version === 4 ? 32 : 128)) throw new Error(CONFIG_ERROR);
return { address: match[1], prefix, family: version === 4 ? "ipv4" : "ipv6" };
}
/** Reject malformed policy rather than silently disabling a requested protection. */
function normalizeRedactionConfig(input) {
try {
if (input === undefined) input = {};
if (!plainObject(input) || Object.keys(input).some((key) => !CONFIG_KEYS.has(key))) {
throw new Error(CONFIG_ERROR);
}
const config = {
enabled: input.enabled === undefined ? false : input.enabled,
redactPrivateIps: input.redactPrivateIps === undefined ? false : input.redactPrivateIps,
cidrs: input.cidrs === undefined ? [] : input.cidrs,
patterns: input.patterns === undefined ? [] : input.patterns,
};
if (typeof config.enabled !== "boolean" || typeof config.redactPrivateIps !== "boolean") {
throw new Error(CONFIG_ERROR);
}
for (const key of ["cidrs", "patterns"]) {
if (!Array.isArray(config[key]) || config[key].length > 100
|| config[key].some((item) => typeof item !== "string" || !item || item.length > 1024)) {
throw new Error(CONFIG_ERROR);
}
}
config.cidrs.forEach(parseCidr);
// RE2 rejects backreferences/lookaround and has bounded execution time.
config.patterns.forEach((pattern) => {
if (RE2JS.compile(pattern).matcher("").find()) throw new Error(CONFIG_ERROR);
});
return { ...config, cidrs: [...config.cidrs], patterns: [...config.patterns] };
} catch {
throw new Error(CONFIG_ERROR);
}
}
function normalizedKey(key) {
return key.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
}
function secretKey(key) {
return /(?:password|passwd|passphrase|pwd|token|secret|apikey|accesskey|secretkey|privatekey|credentials?)$/
.test(normalizedKey(key));
}
function hostKey(key) {
return /^(?:host|hostname|address|ip|ipaddress|server|serveraddress|endpoint|remotehost|targethost|bindaddress|localaddress|remoteaddress)$/
.test(normalizedKey(key));
}
function hostname(value) {
return value.length <= 253 && /[A-Za-z]/.test(value)
&& value.replace(/\.$/, "").split(".").every((label) => (
/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label)
));
}
function addSubnet(block, cidr) {
const { address, prefix, family } = parseCidr(cidr);
block.addSubnet(address, prefix, family);
}
const privateAddresses = new BlockList();
[
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
"169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16",
"224.0.0.0/4", "240.0.0.0/4",
"::/128", "::1/128", "fc00::/7", "fe80::/10", "ff00::/8",
].forEach((cidr) => addSubnet(privateAddresses, cidr));
function escapedLiteral(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Session-local reversible pseudonyms. No reverse map leaves this closure.
* Operate on parsed JSON, never on an RPC envelope or an encoded attachment.
*/
function createRedactionSession(input) {
const config = normalizeRedactionConfig(input);
if (!config.enabled) return Object.freeze({ redact: (value) => value, restore: (value) => value });
const nonce = randomBytes(6).toString("hex");
const originals = new Map();
const tokens = new Map();
const knownSecrets = new Set();
const knownHosts = new Set();
const counters = { IP: 0, HOST: 0, SECRET: 0 };
let storedBytes = 0;
let exhausted = false;
const customRanges = new BlockList();
config.cidrs.forEach((cidr) => addSubnet(customRanges, cidr));
const patterns = config.patterns.map((pattern) => RE2JS.compile(pattern));
function issue(value, namespace) {
// The first classification names a value; later contexts reuse its token.
const identity = `${typeof value}\0${String(value)}`;
const prior = tokens.get(identity);
if (prior) return prior;
const bytes = Buffer.byteLength(identity, "utf8");
if (originals.size >= MAX_ENTRIES || storedBytes + bytes > MAX_BYTES) {
exhausted = true;
throw new Error(LIMIT_ERROR);
}
const token = `[NC_${nonce}_${namespace}_${++counters[namespace]}]`;
tokens.set(identity, token);
originals.set(token, value);
storedBytes += bytes;
return token;
}
function rememberSecret(value) {
if (typeof value !== "string" || !value) return;
issue(value, "SECRET");
knownSecrets.add(value);
}
function shouldMaskIp(address) {
const version = isIP(address);
if (!version) return false;
const family = version === 4 ? "ipv4" : "ipv6";
return config.redactPrivateIps || customRanges.check(address, family)
|| !privateAddresses.check(address, family);
}
function inspectString(value, key, inheritedSecret, budget, acceptExistingTokens = false) {
const source = value;
const aliases = [];
if (acceptExistingTokens) {
value = source.replace(RESERVED_TOKEN, (token, start) => {
if (!originals.has(token)) throw new Error(TOKEN_ERROR);
if (++budget.ranges > MAX_NODES) throw new Error(VALUE_ERROR);
aliases.push({ start, end: start + token.length, token });
// Keep offsets and credential context around aliases. In particular,
// password=prefix<alias>suffix must mask both unaliased fragments.
return "x".repeat(token.length);
});
}
const ranges = [...aliases];
function appendRange(start, end, namespace, remember) {
if (end <= start) return;
if (++budget.ranges > MAX_NODES) throw new Error(VALUE_ERROR);
ranges.push({ start, end, namespace });
if (namespace === "SECRET" && remember) rememberSecret(source.slice(start, end));
if (namespace === "HOST") {
const host = source.slice(start, end);
issue(host, "HOST");
knownHosts.add(host);
}
}
function add(start, end, namespace = "SECRET", remember = true) {
let low = 0;
let high = aliases.length;
while (low < high) {
const mid = (low + high) >>> 1;
if (aliases[mid].end <= start) low = mid + 1;
else high = mid;
}
let cursor = start;
for (let index = low; index < aliases.length && aliases[index].start < end; index++) {
appendRange(cursor, Math.min(end, aliases[index].start), namespace, remember);
cursor = Math.max(cursor, aliases[index].end);
}
appendRange(cursor, end, namespace, remember);
}
function matches(regex, select) {
for (const match of value.matchAll(regex)) {
const group = select(match);
if (group !== undefined && match.indices[group]) add(...match.indices[group]);
}
}
// Source text that resembles a token must survive a one-pass round trip.
// Do not remember these literals as credentials or scan replacement tokens.
for (const match of value.matchAll(RESERVED_TOKEN)) {
add(match.index, match.index + match[0].length, "SECRET", false);
}
if (value && (inheritedSecret || secretKey(key))) add(0, value.length);
matches(/-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/gd, () => 0);
matches(/\bBearer(\s+)([A-Za-z0-9._~+/=-]{8,})/gdi, () => 2);
matches(/(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s@]+)(@)/gdi, () => 2);
// Keep the original separators, whitespace, quotes, and escaped contents.
for (const match of value.matchAll(/(?<![A-Za-z0-9_.-])(["']?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["'])/g)) {
if (!secretKey(match[2])) continue;
const start = match.index + match[0].length;
let end = start;
for (; end < value.length; end++) {
if (value[end] === "\\") end++;
else if (value[end] === match[4]) break;
}
add(start, Math.min(end, value.length));
}
matches(/(?<![A-Za-z0-9_.-])([A-Za-z0-9_.-]+)(\s*[:=]\s*)([^\s"',;{}\[\]]+)/gd,
(match) => secretKey(match[1]) && match[3] !== "null" ? 3 : undefined);
matches(/--(?:password|passwd|passphrase|pwd|token|secret|api-key|access-key|secret-key|private-key)(?:\s+|=)(?:"((?:\\[\s\S]|[^"\\])*)"|'((?:\\[\s\S]|[^'\\])*)'|([^\s]+))/gdi,
(match) => match[1] !== undefined ? 1 : match[2] !== undefined ? 2 : 3);
matches(/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,})\b/gd, () => 0);
for (const pattern of patterns) {
const matcher = pattern.matcher(value);
while (matcher.find()) add(matcher.start(), matcher.end());
}
for (const match of value.matchAll(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g)) {
const start = match.index;
const end = start + match[0].length;
if (!/[A-Za-z0-9_.]/.test(value[start - 1] || "")
&& !/[A-Za-z0-9_.]/.test(value[end] || "") && shouldMaskIp(match[0])) {
add(start, end, "IP");
}
}
for (const match of value.matchAll(/[A-Fa-f0-9:.]+(?:%[A-Za-z0-9_.-]+)?/g)) {
let address = match[0];
if (!address.includes(":")) continue;
// Full stops/colons can be sentence punctuation following an address.
while (address && !isIP(address) && /[.:]$/.test(address)) address = address.slice(0, -1);
if (isIP(address) === 6 && shouldMaskIp(address)) {
add(match.index, match.index + address.length, "IP");
}
}
function inspectHost(start, end) {
const raw = value.slice(start, end);
const trimmed = raw.trim();
const offset = start + raw.indexOf(trimmed);
if (!trimmed || isIP(trimmed)) return;
// In a structured address, only the authority is a hostname, not a path.
const authority = /^(?:[a-z][a-z0-9+.-]*:\/\/)?([^/?#:\s]+)(?::\d+)?(?:[/?#].*)?$/i.exec(trimmed);
if (authority && !isIP(authority[1]) && hostname(authority[1])) {
const hostStart = offset + trimmed.indexOf(authority[1]);
add(hostStart, hostStart + authority[1].length, "HOST");
}
}
if (hostKey(key)) inspectHost(0, value.length);
for (const match of value.matchAll(/(?<![A-Za-z0-9_.-])(["']?)([A-Za-z0-9_.-]+)\1\s*[:=]\s*(?:"([^"]*)"|'([^']*)'|([^\s,;{}]+))/gd)) {
if (!hostKey(match[2])) continue;
const group = match[3] !== undefined ? 3 : match[4] !== undefined ? 4 : 5;
inspectHost(...match.indices[group]);
}
return { value: source, ranges };
}
function rejectBinary(value) {
const type = typeof value.type === "string" ? value.type.toLowerCase() : "";
if (type === "image" || type === "audio") throw new Error(VALUE_ERROR);
for (const [key, entry] of Object.entries(value)) {
if (/^(?:base64data|base64|blob)$/.test(normalizedKey(key))
&& entry !== undefined && entry !== null && entry !== "") throw new Error(VALUE_ERROR);
}
const mime = typeof value.mimeType === "string" ? value.mimeType : value.mime_type;
if (typeof mime === "string" && !/^(?:text\/|application\/(?:json|xml|javascript)(?:;|$))/i.test(mime)
&& value.data !== undefined && value.data !== null && value.data !== "") {
throw new Error(VALUE_ERROR);
}
}
function walk(value, transformString, transformPrimitive, key = "", inheritedSecret = false, state) {
if (++state.nodes > MAX_NODES || state.depth > MAX_DEPTH) throw new Error(VALUE_ERROR);
if (typeof value === "string") {
if (value.includes("\0")) throw new Error(VALUE_ERROR);
state.bytes += Buffer.byteLength(value, "utf8");
if (state.bytes > MAX_BYTES) throw new Error(VALUE_ERROR);
return transformString(value, key, inheritedSecret);
}
if (value === null || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) {
return transformPrimitive(value, key, inheritedSecret);
}
if (!Array.isArray(value) && !plainObject(value)) throw new Error(VALUE_ERROR);
if (state.seen.has(value)) throw new Error(VALUE_ERROR);
state.seen.add(value);
state.depth++;
let result;
if (Array.isArray(value)) {
// Match JSON transport semantics for optional in-process values.
result = Array.from(value, (entry) => walk(
entry === undefined ? null : entry, transformString, transformPrimitive, key, inheritedSecret, state,
));
} else {
rejectBinary(value);
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).map(([entryKey, entry]) => [
state.preserveRootKeys && state.depth === 1
? entryKey : walk(entryKey, transformString, transformPrimitive, "", false, state),
walk(entry, transformString, transformPrimitive, entryKey, inheritedSecret || secretKey(key), state),
]);
if (new Set(entries.map(([entryKey]) => String(entryKey))).size !== entries.length) {
throw new Error(VALUE_ERROR);
}
result = Object.fromEntries(entries);
}
state.depth--;
state.seen.delete(value);
return result;
}
function state(preserveRootKeys = false) {
if (exhausted) throw new Error(LIMIT_ERROR);
return { nodes: 0, bytes: 0, depth: 0, seen: new Set(), preserveRootKeys };
}
function redact(value, options) {
const plans = [];
const budget = { ranges: 0 };
const preserveRootKeys = options?.preserveRootKeys === true;
const acceptExistingTokens = options?.acceptExistingTokens === true;
// Inspect the entire value first: repeats appearing before a credential's
// defining field in this response must be masked too.
walk(value, (text, key, secret) => {
plans.push(inspectString(text, key, secret, budget, acceptExistingTokens));
return text;
}, (entry) => entry, "", false, state(preserveRootKeys));
const knownPattern = knownSecrets.size
? new RegExp([...knownSecrets].sort((a, b) => b.length - a.length).map(escapedLiteral).join("|"), "g")
: null;
const knownHostPattern = knownHosts.size
? new RegExp(`(?<![A-Za-z0-9_.-])(?:${[...knownHosts].sort((a, b) => b.length - a.length)
.map(escapedLiteral).join("|")})(?![A-Za-z0-9_.-])`, "g")
: null;
let cursor = 0;
return walk(value, () => {
const { value: text, ranges } = plans[cursor++];
const aliasRanges = ranges.filter((range) => range.token);
for (const [pattern, namespace] of [[knownPattern, "SECRET"], [knownHostPattern, "HOST"]]) {
if (!pattern) continue;
let aliasIndex = 0;
for (const match of text.matchAll(pattern)) {
const end = match.index + match[0].length;
// Previously issued aliases are protocol data, never source secrets.
while (aliasIndex < aliasRanges.length && aliasRanges[aliasIndex].end <= match.index) aliasIndex++;
if (aliasIndex < aliasRanges.length && aliasRanges[aliasIndex].start < end) continue;
if (++budget.ranges > MAX_NODES) throw new Error(VALUE_ERROR);
ranges.push({ start: match.index, end, namespace });
}
}
ranges.sort((a, b) => a.start - b.start || b.end - a.end);
const merged = [];
for (const range of ranges) {
const previous = merged[merged.length - 1];
if (previous && range.start < previous.end) {
previous.end = Math.max(previous.end, range.end);
if (range.namespace === "SECRET") previous.namespace = "SECRET";
} else merged.push({ ...range });
}
let result = "";
let position = 0;
for (const range of merged) {
result += text.slice(position, range.start)
+ (range.token || issue(text.slice(range.start, range.end), range.namespace));
position = range.end;
}
return result + text.slice(position);
}, (entry, key, secret) => (
entry !== null && (secret || secretKey(key)) ? issue(entry, "SECRET") : entry
), "", false, state(preserveRootKeys));
}
function restore(value) {
return walk(value, (text) => {
if (originals.has(text)) return originals.get(text);
return text.replace(RESERVED_TOKEN, (token) => {
if (!originals.has(token)) throw new Error(TOKEN_ERROR);
// A replacement is not scanned again, including a source token literal.
return String(originals.get(token));
});
}, (entry) => entry, "", false, state());
}
function safeTransform(transform, value, options) {
try {
return transform(value, options);
} catch (error) {
// Even a regex/compiler failure must not include source text in an error.
const message = [VALUE_ERROR, TOKEN_ERROR, LIMIT_ERROR].includes(error?.message)
? error.message : VALUE_ERROR;
throw new Error(message);
}
}
return Object.freeze({
redact: (value, options) => safeTransform(redact, value, options),
restore: (value) => safeTransform(restore, value),
});
}
module.exports = { normalizeRedactionConfig, createRedactionSession };
+380
View File
@@ -0,0 +1,380 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createRedactionSession, normalizeRedactionConfig } = require("./externalMcpRedaction.cjs");
const TOKEN = /^\[NC_[a-f0-9]{12}_(?:IP|HOST|SECRET)_\d+\]$/;
const enabledSession = (config = {}) => createRedactionSession({ enabled: true, ...config });
test("policy defaults are disabled, strict, copied, and errors never include the input", () => {
assert.deepEqual(normalizeRedactionConfig(), {
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
});
const input = { enabled: true, patterns: ["synthetic-[0-9]+"], cidrs: ["10.0.0.0/8", "fc00::/7"] };
const config = normalizeRedactionConfig(input);
input.patterns.push("changed");
assert.deepEqual(config.patterns, ["synthetic-[0-9]+"]);
for (const invalid of [
null, [], true, "sensitive-invalid-config", { enabled: "true" },
{ redactPrivateIps: 1 }, { surprise: "sensitive-invalid-config" },
{ cidrs: "10.0.0.0/8" }, { cidrs: ["1.2.3.4/33"] },
{ cidrs: ["::1/129"] }, { cidrs: ["::1%eth0/64"] }, { cidrs: ["10.0.0.0/08"] },
{ cidrs: ["not-an-address/8"] }, { patterns: [""] }, { patterns: [1] },
{ patterns: ["(?=unsupported)"] }, { patterns: ["(secret)\\1"] },
{ patterns: ["("] }, { patterns: ["a*"] }, { patterns: ["^"] },
{ patterns: Array(101).fill("value") },
]) {
assert.throws(() => normalizeRedactionConfig(invalid),
{ message: "Invalid External MCP redaction configuration" });
}
});
test("disabled policy leaves inputs and reserved text alone", () => {
const session = createRedactionSession();
const value = { password: "synthetic-password", content: "[NC_other_SECRET_1] 8.8.8.8" };
assert.equal(session.redact(value), value);
assert.equal(session.restore(value), value);
});
test("optional undefined RPC object members are omitted and array slots normalize to null", () => {
const session = enabledSession();
const params = { chatSessionId: "external", filePath: "/synthetic.txt", filename: undefined };
assert.deepEqual(session.restore(params), { chatSessionId: "external", filePath: "/synthetic.txt" });
assert.equal(Object.hasOwn(params, "filename"), true);
const result = {
filePath: "/synthetic.txt", filename: undefined, base64Data: undefined,
mimeType: "text/plain", content: "8.8.8.8", optional: [undefined, , "text"],
};
const masked = session.redact(result);
assert.equal(Object.hasOwn(masked, "filename"), false);
assert.equal(Object.hasOwn(masked, "base64Data"), false);
assert.match(masked.content, TOKEN);
assert.deepEqual(masked.optional, [null, null, "text"]);
assert.deepEqual(session.restore(masked), JSON.parse(JSON.stringify(result)));
assert.equal(Object.hasOwn(result, "filename"), true);
assert.equal(result.optional[0], undefined);
});
test("recursive transforms include object keys, preserve JSON types, and do not mutate sources", () => {
const session = enabledSession();
const source = {
"8.8.8.8": [{ host: "build.example.test", password: "synthetic-pass" }, null, 42, false],
empty: "", password: "", apiKey: null, tokenCount: 123,
};
const snapshot = structuredClone(source);
const redacted = session.redact(source);
assert.notEqual(redacted, source);
assert.equal(Object.keys(redacted).some((key) => key === "8.8.8.8"), false);
assert.equal(JSON.stringify(redacted).includes("synthetic-pass"), false);
assert.equal(redacted.tokenCount, 123);
assert.equal(redacted.empty, "");
assert.equal(redacted.password, "");
assert.equal(redacted.apiKey, null);
assert.deepEqual(source, snapshot);
assert.deepEqual(session.restore(redacted), source);
assert.deepEqual(source, snapshot);
});
test("RPC root keys can be preserved without exempting nested user keys or secret field values", () => {
const session = enabledSession();
const source = {
stdout: "password=stdout\nimportant output", stderr: "", exitCode: 0,
password: "synthetic-root-secret", details: { stdout: "nested user key", "8.8.8.8": "address key" },
};
const result = session.redact(source, { preserveRootKeys: true });
assert.deepEqual(Object.keys(result), Object.keys(source));
assert.match(result.stdout, /^password=\[NC_[^\]]+\]\nimportant output$/);
assert.match(result.password, TOKEN);
assert.equal(Object.hasOwn(result.details, "stdout"), false);
assert.equal(Object.hasOwn(result.details, "8.8.8.8"), false);
assert.deepEqual(session.restore(result), source);
assert.equal(Object.hasOwn(session.redact(source), "stdout"), false);
assert.equal(source.stdout, "password=stdout\nimportant output");
});
test("same-session values are stable; separate sessions have different token namespaces", () => {
const one = enabledSession();
const two = enabledSession();
const a = one.redact("8.8.8.8");
assert.match(a, TOKEN);
assert.equal(one.redact("8.8.8.8"), a);
assert.notEqual(two.redact("8.8.8.8"), a);
assert.throws(() => two.restore(a), /Unknown External MCP redaction token/);
assert.deepEqual(Object.keys(one).sort(), ["redact", "restore"]);
});
test("public IPv4 and IPv6 are hidden, including bracketed addresses and IPv4-mapped IPv6", () => {
const session = enabledSession();
for (const address of [
"8.8.8.8", "203.0.113.14", "2001:4860:4860::8888", "2001:DB8::AbCd",
"::ffff:8.8.8.8", "::ffff:808:808",
]) {
const masked = session.redact(address);
assert.match(masked, TOKEN, address);
assert.match(masked, /_IP_/);
assert.equal(session.restore(masked), address);
}
const url = "https://[2001:db8::1234]:443/path";
const masked = session.redact(url);
assert.match(masked, /^https:\/\/\[\[NC_[^\]]+_IP_\d+\]\]:443\/path$/);
assert.equal(session.restore(masked), url);
assert.equal(session.redact("invalid 999.999.999.999 and 1.2.3.4.5"), "invalid 999.999.999.999 and 1.2.3.4.5");
});
test("private, loopback, link-local, CGNAT, and mapped private addresses are preserved by default", () => {
const session = enabledSession();
for (const address of [
"10.2.3.4", "172.16.0.1", "172.31.255.255", "192.168.1.1", "127.0.0.1",
"169.254.12.9", "100.64.0.1", "100.127.255.254", "::", "::1",
"fd12:3456::1", "fc00::5", "fe80::cafe%eth0", "::ffff:10.0.0.1", "::ffff:7f00:1",
]) {
assert.equal(session.redact(address), address);
assert.deepEqual(session.redact({ host: address }), { host: address });
}
assert.match(session.redact("100.128.0.1"), TOKEN);
assert.match(session.redact("172.32.0.1"), TOKEN);
});
test("private-address switch and CIDR opt-ins handle IPv4, IPv6, and zone spelling", () => {
const all = enabledSession({ redactPrivateIps: true });
for (const address of ["10.0.0.1", "::1", "fe80::1%eth0", "::ffff:10.0.0.1"]) {
const token = all.redact(address);
assert.match(token, TOKEN);
assert.equal(all.restore(token), address);
}
const scoped = enabledSession({ cidrs: ["10.4.0.0/16", "fd12:3456::/32"] });
assert.match(scoped.redact("10.4.1.8"), TOKEN);
assert.equal(scoped.redact("10.5.1.8"), "10.5.1.8");
assert.match(scoped.redact("fd12:3456:abcd::8"), TOKEN);
assert.equal(scoped.redact("fd12:3457::8"), "fd12:3457::8");
});
test("hostnames require structured host/address context, not arbitrary dotted words", () => {
const session = enabledSession();
const source = {
hostname: "build.internal.test", address: "ssh.example.test:2222",
remoteHost: "ubuntu", endpoint: "https://api.example.test/path?q=1",
description: "README.md and foo.example.test are ordinary prose",
};
const masked = session.redact(source);
assert.match(masked.hostname, /_HOST_/);
assert.match(masked.address, /_HOST_\d+\]:2222$/);
assert.match(masked.remoteHost, /_HOST_/);
assert.match(masked.endpoint, /^https:\/\/\[NC_[^\]]+_HOST_\d+\]\/path\?q=1$/);
assert.equal(masked.description, source.description);
assert.deepEqual(session.restore(masked), source);
assert.match(session.redact('hostname = "build.internal.test"'), /^hostname = "\[NC_/);
assert.equal(session.redact({ host: "not a hostname" }).host, "not a hostname");
});
test("credential recognizers preserve separators, quotes, whitespace, and escaped JSON contents", () => {
const session = enabledSession();
const values = [
'PASSWORD \t= \t"synthetic pass\\"with quote"; unchanged',
"api_key: 'synthetic\\\\escaped-value'",
'{"password" : "synthetic\\nline\\tvalue", "ok": true}',
'message = "password = \'synthetic-inner-quoted-value\'"',
"token = synthetic_assignment_value; tokenCount=91",
"curl --password 'synthetic cli pass' --api-key=synthetic_cli_key",
"Authorization: Bearer \t SYNTHETIC_bearer_12345678",
"https://alice:synthetic-url-password@service.example.test/path",
"ghp_SYNTHETIC0000000000000000000001",
"sk-SYNTHETIC0000000000000000000002",
];
for (const value of values) {
const masked = session.redact(value);
assert.notEqual(masked, value, value);
assert.equal(session.restore(masked), value);
}
assert.equal(session.redact('password = ""'), 'password = ""');
assert.equal(session.redact("password = null"), "password = null");
assert.equal(session.redact("tokenCount = 91; authType = bearer"), "tokenCount = 91; authType = bearer");
});
test("PEM private key blocks are a single secret, including every header and footer", () => {
const session = enabledSession();
const key = "-----BEGIN OPENSSH PRIVATE KEY-----\nSYNTHETIC-ONLY-NOT-A-KEY\n-----END OPENSSH PRIVATE KEY-----";
const result = session.redact(`before\n${key}\nafter`);
assert.match(result, /^before\n\[NC_[a-f0-9]+_SECRET_\d+\]\nafter$/);
assert.equal(session.restore(result), `before\n${key}\nafter`);
});
test("repeated credentials are masked even before their defining field and in later responses", () => {
const session = enabledSession();
const source = { echo: "synthetic-repeat", password: "synthetic-repeat", nested: ["synthetic-repeat"] };
const masked = session.redact(source);
assert.match(masked.password, TOKEN);
assert.equal(masked.echo, masked.password);
assert.equal(masked.nested[0], masked.password);
assert.equal(session.redact("echo synthetic-repeat"), `echo ${masked.password}`);
assert.deepEqual(session.restore(masked), source);
});
test("known host literals repeat across free text without matching ordinary-word substrings", () => {
const session = enabledSession();
const source = {
before: "connect to db or build.example.test",
hostname: "db",
nested: { host: "build.example.test" },
};
const masked = session.redact(source);
assert.equal(masked.before, `connect to ${masked.hostname} or ${masked.nested.host}`);
assert.equal(session.redact("ssh db; ping build.example.test"),
`ssh ${masked.hostname}; ping ${masked.nested.host}`);
assert.equal(session.redact("dbx adb db.internal build.example.test.other"),
"dbx adb db.internal build.example.test.other");
assert.deepEqual(session.restore(masked), source);
});
test("managed boundaries can preserve known aliases without changing external literal-token behavior", () => {
const session = enabledSession();
const source = { host: "db", password: 123456, secret: false, text: "8.8.8.8" };
const aliases = session.redact(source);
assert.deepEqual(session.redact(aliases, { acceptExistingTokens: true }), aliases);
const repeated = { text: `${aliases.host} ${aliases.password} ${aliases.secret} ${aliases.text}` };
assert.deepEqual(session.redact(repeated, { acceptExistingTokens: true }), repeated);
assert.equal(session.redact(aliases.password, { acceptExistingTokens: true }), aliases.password);
assert.equal(session.restore(aliases.password), 123456);
assert.equal(session.restore(aliases.secret), false);
assert.notEqual(session.redact(aliases.host), aliases.host);
assert.throws(() => session.redact("[NC_unknown_HOST_1]", { acceptExistingTokens: true }),
/Unknown External MCP redaction token/);
assert.throws(() => session.redact("[NC_unclosed", { acceptExistingTokens: true }),
/Unknown External MCP redaction token/);
});
test("managed aliases stay intact alongside new secrets and do not become learned secrets", () => {
const session = enabledSession();
const token = session.redact("8.8.8.8");
const mixed = session.redact({ password: `fresh-secret ${token} another-secret`, text: token },
{ acceptExistingTokens: true });
assert.equal(mixed.text, token);
assert.equal(mixed.password.includes(token), true);
assert.equal(mixed.password.includes("fresh-secret"), false);
assert.deepEqual(session.restore(mixed), { password: "fresh-secret 8.8.8.8 another-secret", text: "8.8.8.8" });
assert.equal(session.redact(token, { acceptExistingTokens: true }), token);
});
test("managed aliases do not break credential or custom-pattern context around raw fragments", () => {
const session = enabledSession({ patterns: ["SENSITIVE-BEGIN[\\s\\S]*?SENSITIVE-END"] });
const token = session.redact("8.8.8.8");
for (const source of [
`password=raw-prefix${token}raw-suffix`,
`--password 'quoted-prefix${token}quoted-suffix'`,
`SENSITIVE-BEGIN${token}SENSITIVE-END`,
]) {
const masked = session.redact(source, { acceptExistingTokens: true });
assert.equal(masked.includes(token), true);
assert.equal(masked.includes("raw-prefix"), false);
assert.equal(masked.includes("raw-suffix"), false);
assert.equal(masked.includes("quoted-prefix"), false);
assert.equal(masked.includes("quoted-suffix"), false);
assert.equal(masked.includes("SENSITIVE-BEGIN"), false);
assert.equal(masked.includes("SENSITIVE-END"), false);
assert.equal(session.restore(masked), source.replace(token, "8.8.8.8"));
}
});
test("a value keeps its first token even when later recognized in a different namespace", () => {
const session = enabledSession();
const ip = session.redact("8.8.8.8");
assert.equal(session.redact({ password: "8.8.8.8" }).password, ip);
const host = session.redact({ host: "synthetic.example.test" }).host;
assert.equal(session.redact({ secret: "synthetic.example.test" }).secret, host);
});
test("structured numeric and nested credentials restore their original types", () => {
const session = enabledSession();
const source = { token: 123456, credentials: { password: "synthetic-nested", values: ["synthetic-value"] }, tokenCount: 123456 };
const result = session.redact(source);
assert.match(result.token, TOKEN);
assert.equal(result.tokenCount, 123456);
assert.deepEqual(session.restore(result), source);
});
test("custom RE2 patterns redact whole matches, including across lines and overlapping defaults", () => {
const session = enabledSession({ patterns: ["ACCOUNT-[0-9]+", "BEGIN[\\s\\S]*?END", "address=8\\.8\\.8\\.8"] });
const source = "ACCOUNT-123 and ACCOUNT-123; BEGIN\nsynthetic\nEND; address=8.8.8.8";
const result = session.redact(source);
assert.equal(result.includes("ACCOUNT-123"), false);
assert.equal(result.includes("BEGIN"), false);
assert.equal(result.includes("address="), false);
assert.equal(session.restore(result), source);
assert.equal(session.redact(""), "");
});
test("literal reserved tokens are escaped and restoration never rescans replacement text", () => {
const session = enabledSession();
const issued = session.redact("8.8.8.8");
for (const literal of [
"[NC_deadbeef0000_IP_1]", "[NC_other_SECRET_123]", "[NC_invalid]", "[NC_unclosed", issued,
]) {
const escaped = session.redact(`literal=${literal}`);
assert.equal(escaped.includes(literal), false);
assert.equal(session.restore(escaped), `literal=${literal}`);
}
const escapedActual = session.redact(issued);
assert.notEqual(escapedActual, issued);
assert.equal(session.restore(escapedActual), issued);
assert.equal(session.restore(issued), "8.8.8.8");
});
test("unknown, modified, cross-session tokens and tokens in object keys fail closed", () => {
const session = enabledSession();
const issued = session.redact("8.8.8.8");
for (const unknown of [
"[NC_unknown_IP_1]", "[NC_unclosed", issued.replace("_IP_", "_HOST_"), `${issued.slice(0, -1)}0]`,
]) {
assert.throws(() => session.restore(`ping ${unknown}`), /Unknown External MCP redaction token/);
assert.throws(() => session.restore({ [unknown]: "test" }), /Unknown External MCP redaction token/);
}
});
test("restored object-key collisions fail closed and prototype-shaped keys remain plain data", () => {
const session = enabledSession();
const token = session.redact("8.8.8.8");
assert.throws(() => session.restore({ [token]: 1, "8.8.8.8": 2 }), /could not safely process/);
const source = JSON.parse('{"__proto__": "8.8.8.8", "constructor": "normal"}');
const result = session.restore(session.redact(source));
assert.deepEqual(result, source);
assert.equal(Object.getPrototypeOf(result), Object.prototype);
assert.equal(Object.hasOwn(result, "__proto__"), true);
});
test("nontext attachments and non-JSON data fail closed, while ordinary text attachments work", () => {
const session = enabledSession();
for (const source of [
{ base64Data: "U1lOVEhFVElD" },
{ content: [{ type: "image", data: "U1lOVEhFVElD", mimeType: "image/png" }] },
{ content: [{ type: "audio", data: "U1lOVEhFVElD", mimeType: "audio/wav" }] },
{ resource: { blob: "U1lOVEhFVElD", mimeType: "application/octet-stream" } },
{ data: "U1lOVEhFVElD", mimeType: "application/pdf" },
"binary\0text", Buffer.from("synthetic"), new Date(), undefined, Number.NaN,
]) {
assert.throws(() => session.redact(source), { message: "External MCP redaction could not safely process this value" });
}
const text = { content: [{ type: "text", text: "8.8.8.8" }], mimeType: "text/plain" };
assert.deepEqual(session.restore(session.redact(text)), text);
const cyclic = {};
cyclic.self = cyclic;
assert.throws(() => session.redact(cyclic), /could not safely process/);
});
test("session growth fails closed without evicting or reassigning tokens", () => {
const session = enabledSession();
const first = session.redact("8.8.8.8");
const many = Array.from({ length: 10001 }, (_, index) => ({ hostname: `host-${index}.example.test` }));
assert.throws(() => session.redact(many), {
message: "External MCP redaction session limit reached; reconnect the client",
});
assert.throws(() => session.redact("8.8.8.8"), /session limit reached/);
assert.throws(() => session.restore(first), /session limit reached/);
});
test("large ordinary strings and bounded RE2 nested quantifiers do not use backtracking scans", () => {
const session = enabledSession({ patterns: ["(a+)+$"] });
const source = `${"a".repeat(50000)}!`;
assert.equal(session.redact(source), source);
assert.equal(session.restore(source), source);
});
+14 -3
View File
@@ -20,6 +20,8 @@ function catalogDescription(toolName, fallback) {
return getCatalogToolDescription(toolName) || fallback;
}
const { createPrivacyBoundary } = require("./externalMcpPrivacyBoundary.cjs");
const DEBUG_MCP = process.env.NETCATTY_MCP_DEBUG === "1";
function debugLog(...args) {
@@ -191,11 +193,20 @@ function rpcCall(method, params) {
const id = nextRpcId++;
pendingRequests.set(id, { resolve, reject });
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
debugLog("rpcCall", { id, method, params });
debugLog("rpcCall", { id, method });
tcpSocket.write(msg);
});
}
// External clients own connection maps here; managed agents use the main-process chat boundary.
const externalDiscoveryPath = process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
const exposedRpcCall = externalDiscoveryPath
? createPrivacyBoundary(rpcCall, () => {
const discovery = JSON.parse(fs.readFileSync(externalDiscoveryPath, "utf8"));
return discovery.redaction;
})
: rpcCall;
// ── MCP Server ──
const server = new McpServer({
@@ -219,7 +230,7 @@ server.resource(
"netcatty://context",
{ description: "Current Netcatty workspace context: connected hosts, session IDs, and environment description." },
async () => {
const ctx = await rpcCall("netcatty/getContext", scopeParams);
const ctx = await exposedRpcCall("netcatty/getContext", scopeParams);
return {
contents: [{
uri: "netcatty://context",
@@ -232,7 +243,7 @@ server.resource(
// Start with core tools; load specialized catalog groups through load_netcatty_tools.
registerProgressiveMcpTools(server, {
rpcCall,
rpcCall: exposedRpcCall,
scopeParams,
guardWriteOperation,
catalogDescription,
+3
View File
@@ -1997,6 +1997,9 @@ function createPreloadApi(ctx) {
return ipcRenderer.invoke("netcatty:external-mcp:grok:add");
},
// MCP Server session metadata
aiPrivacyStatus: async () => ipcRenderer.invoke("netcatty:ai:privacy:status"),
aiPrivacyTransform: async (request) => ipcRenderer.invoke("netcatty:ai:privacy:transform", request),
aiPrivacyClearSession: async (chatSessionId) => ipcRenderer.invoke("netcatty:ai:privacy:clear", chatSessionId),
aiMcpUpdateSessions: async (sessions, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:mcp:update-sessions", { sessions, chatSessionId });
},
+187
View File
@@ -0,0 +1,187 @@
"use strict";
const {
createRedactionSession,
normalizeRedactionConfig,
} = require("../mcp/externalMcpRedaction.cjs");
const CONFIG_ERROR = "Invalid AI privacy configuration";
const LOADING_ERROR = "AI privacy settings are still loading; try again";
const SESSION_ERROR = "AI privacy session is unavailable or its settings changed; start a new chat";
const SESSION_ID_ERROR = "AI privacy requires a valid chat session";
const LIMIT_ERROR = "AI privacy session limit reached; close a chat and try again";
const VALUE_ERROR = "AI privacy could not safely process this value";
const TOKEN_ERROR = "Unknown AI privacy token; start a new chat";
const VALUE_LIMIT_ERROR = "AI privacy session limit reached; start a new chat";
/**
* Main-process privacy state. Only the validated policy may be persisted by a
* caller; alias maps and retired chat identities never leave this closure.
*
* checkSession pins the policy even while privacy is disabled. A policy change
* retires all pinned chats, so switching privacy off cannot leak an old turn.
* Adapters pass semantic content, not complete SDK/RPC envelopes. Root object
* keys are control fields and stay intact; nested user-data keys are redacted.
*/
function createAiPrivacyService({ maxSessions = 64, initialized = true } = {}) {
if (!Number.isSafeInteger(maxSessions) || maxSessions < 1 || maxSessions > 4096
|| typeof initialized !== "boolean") {
throw new Error(CONFIG_ERROR);
}
let config = normalizeRedactionConfig();
let fingerprint = JSON.stringify(config);
let invalidConfiguration = false;
let configurationInitialized = initialized;
let policyRevision = 0;
// Only enabled chats allocate secret-bearing maps. Disabled and retired
// identities retain no content and must not impose a limit on ordinary use.
const sessions = new Map();
const disabledSessions = new Set();
const disabledSession = Object.freeze({ redactor: null });
const closedSessions = new Set();
function validateId(chatSessionId) {
if (typeof chatSessionId !== "string" || !chatSessionId.trim()
|| chatSessionId.length > 512 || chatSessionId.includes("\0")) {
throw new Error(SESSION_ID_ERROR);
}
}
function retire(chatSessionId) {
sessions.delete(chatSessionId);
disabledSessions.delete(chatSessionId);
// Keep only the ID, never its alias map. Do not evict a retired identity:
// a late callback must not recreate it, even after a configuration change.
closedSessions.add(chatSessionId);
}
function retireAll() {
for (const chatSessionId of sessions.keys()) retire(chatSessionId);
for (const chatSessionId of disabledSessions) retire(chatSessionId);
}
function invalidateConfiguration() {
policyRevision += 1;
invalidConfiguration = true;
retireAll();
}
function requireValidConfig() {
if (invalidConfiguration) throw new Error(CONFIG_ERROR);
if (!configurationInitialized) throw new Error(LOADING_ERROR);
}
function configure(input) {
let next;
try {
next = normalizeRedactionConfig(input);
} catch {
invalidateConfiguration();
throw new Error(CONFIG_ERROR);
}
const nextFingerprint = JSON.stringify(next);
if (nextFingerprint !== fingerprint) {
policyRevision += 1;
retireAll();
}
config = next;
fingerprint = nextFingerprint;
invalidConfiguration = false;
configurationInitialized = true;
return getConfig();
}
// External clients own their maps elsewhere, but delayed effects still need
// to honor a policy change in main, including a change away and back.
function createPolicyGuard() {
requireValidConfig();
const revision = policyRevision;
return () => {
requireValidConfig();
if (revision !== policyRevision) throw new Error(SESSION_ERROR);
};
}
function getConfig() {
requireValidConfig();
return { ...config, cidrs: [...config.cidrs], patterns: [...config.patterns] };
}
function isEnabled() {
requireValidConfig();
return config.enabled;
}
function hasSession(chatSessionId) {
return configurationInitialized && !invalidConfiguration && !!sessions.get(chatSessionId)?.redactor;
}
function getSession(chatSessionId, create = true) {
requireValidConfig();
validateId(chatSessionId);
if (closedSessions.has(chatSessionId)) throw new Error(SESSION_ERROR);
if (!config.enabled) {
if (!create && !disabledSessions.has(chatSessionId)) throw new Error(SESSION_ERROR);
disabledSessions.add(chatSessionId);
return disabledSession;
}
const existing = sessions.get(chatSessionId);
if (existing) return existing;
if (!create) throw new Error(SESSION_ERROR);
if (sessions.size >= maxSessions) throw new Error(LIMIT_ERROR);
const entry = { redactor: createRedactionSession(config) };
sessions.set(chatSessionId, entry);
return entry;
}
function checkSession(chatSessionId) {
return !!getSession(chatSessionId).redactor;
}
function transform(operation, chatSessionId, value) {
const { redactor } = getSession(chatSessionId, operation === "redact");
if (!redactor) return value;
try {
if (operation === "restore") return redactor.restore(value);
// Also validate aliases in preserved root keys. Do not use this restored
// value for redaction: whole-string numeric/boolean aliases must keep
// their token and type when they cross another model boundary.
redactor.restore(value);
return redactor.redact(value, { preserveRootKeys: true, acceptExistingTokens: true });
} catch (error) {
if (error?.message === "Unknown External MCP redaction token; reconnect the client") {
throw new Error(TOKEN_ERROR);
}
if (error?.message === "External MCP redaction session limit reached; reconnect the client") {
throw new Error(VALUE_LIMIT_ERROR);
}
throw new Error(VALUE_ERROR);
}
}
function clearSession(chatSessionId) {
validateId(chatSessionId);
// Tombstone even an unseen ID: a deleted chat may have an in-flight first
// model call which must not create its initial map after cleanup.
retire(chatSessionId);
}
return Object.freeze({
configure,
getConfig,
createPolicyGuard,
invalidateConfiguration,
isEnabled,
hasSession,
checkSession,
redact: (chatSessionId, value) => transform("redact", chatSessionId, value),
restore: (chatSessionId, value) => transform("restore", chatSessionId, value),
clearSession,
});
}
// The persisted policy arrives asynchronously from the renderer. Never treat
// "not loaded yet" as a disabled policy and allow an unprotected first turn.
const aiPrivacyService = createAiPrivacyService({ initialized: false });
module.exports = { ...aiPrivacyService, aiPrivacyService, createAiPrivacyService };
+293
View File
@@ -0,0 +1,293 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const moduleExports = require("./aiPrivacyService.cjs");
const { createAiPrivacyService, aiPrivacyService } = moduleExports;
const TOKEN = /^\[NC_[a-f0-9]{12}_(?:IP|HOST|SECRET)_\d+\]$/;
const NEW_CHAT = /start a new chat/;
const enabledService = (policy = {}, limits) => {
const service = createAiPrivacyService(limits);
service.configure({ enabled: true, ...policy });
return service;
};
test("singleton and factory expose validated, copied, disabled-by-default configuration", () => {
assert.equal(moduleExports.redact, aiPrivacyService.redact);
assert.throws(() => aiPrivacyService.isEnabled(), /settings are still loading/);
const service = createAiPrivacyService();
assert.deepEqual(service.getConfig(), { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] });
assert.equal(service.isEnabled(), false);
const source = { password: "synthetic-password", binary: Buffer.from("synthetic") };
assert.equal(service.redact("disabled-chat", source), source);
assert.equal(service.restore("disabled-chat", source), source);
assert.equal(service.checkSession("disabled-chat"), false);
assert.equal(service.hasSession("disabled-chat"), false);
const policy = { enabled: true, patterns: ["ACCOUNT-[0-9]+"], cidrs: ["10.0.0.0/8"] };
const configured = service.configure(policy);
configured.patterns.push("changed");
policy.cidrs.push("192.168.0.0/16");
assert.deepEqual(service.getConfig().patterns, ["ACCOUNT-[0-9]+"]);
assert.deepEqual(service.getConfig().cidrs, ["10.0.0.0/8"]);
const read = service.getConfig();
read.enabled = false;
read.cidrs.length = 0;
assert.equal(service.isEnabled(), true);
assert.deepEqual(service.getConfig().cidrs, ["10.0.0.0/8"]);
assert.throws(() => aiPrivacyService.isEnabled(), /settings are still loading/);
});
test("production singleton fails closed until persisted policy is initialized successfully", () => {
const startupOperations = [
() => aiPrivacyService.isEnabled(),
() => aiPrivacyService.getConfig(),
() => aiPrivacyService.checkSession("startup"),
() => aiPrivacyService.redact("startup", "8.8.8.8"),
() => aiPrivacyService.restore("startup", "8.8.8.8"),
];
for (const operation of startupOperations) {
assert.throws(operation, { message: "AI privacy settings are still loading; try again" });
}
assert.equal(aiPrivacyService.hasSession("startup"), false);
assert.throws(() => aiPrivacyService.configure({ enabled: "corrupt-saved-value" }),
{ message: "Invalid AI privacy configuration" });
for (const operation of startupOperations) {
assert.throws(operation, { message: "Invalid AI privacy configuration" });
}
aiPrivacyService.configure({ enabled: true });
assert.equal(aiPrivacyService.isEnabled(), true);
// Failed pre-initialization calls must not leave a disabled snapshot behind.
assert.match(aiPrivacyService.redact("startup", "8.8.8.8"), TOKEN);
aiPrivacyService.invalidateConfiguration();
assert.throws(() => aiPrivacyService.isEnabled(), /Invalid AI privacy configuration/);
aiPrivacyService.configure({ enabled: false });
assert.equal(aiPrivacyService.isEnabled(), false);
assert.throws(() => aiPrivacyService.redact("startup", "8.8.8.8"), NEW_CHAT);
assert.equal(aiPrivacyService.redact("loaded-disabled", "8.8.8.8"), "8.8.8.8");
});
test("a pending factory can initialize a valid disabled policy without blocking its first chat", () => {
const service = createAiPrivacyService({ initialized: false });
assert.throws(() => service.checkSession("waiting"), /settings are still loading/);
service.configure({});
assert.equal(service.checkSession("waiting"), false);
assert.equal(service.redact("waiting", "8.8.8.8"), "8.8.8.8");
});
test("maps are isolated per stable chat identity and per service instance", () => {
const service = enabledService();
assert.equal(service.hasSession("chat-one"), false);
assert.equal(service.checkSession("chat-one"), true);
assert.equal(service.hasSession("chat-one"), true);
const one = service.redact("chat-one", "8.8.8.8");
const two = service.redact("chat-two", "8.8.8.8");
assert.match(one, TOKEN);
assert.match(two, TOKEN);
assert.notEqual(one, two);
assert.equal(service.redact("chat-one", "8.8.8.8"), one);
assert.equal(service.restore("chat-one", one), "8.8.8.8");
assert.throws(() => service.restore("chat-two", one), /Unknown AI privacy token/);
assert.throws(() => service.redact("chat-two", one), /Unknown AI privacy token/);
const other = enabledService();
other.checkSession("chat-one");
assert.throws(() => other.restore("chat-one", one), /Unknown AI privacy token/);
assert.notEqual(other.redact("chat-one", "8.8.8.8"), one);
});
test("multiple model boundaries preserve existing aliases, including numeric and boolean secrets", () => {
const service = enabledService();
const source = { host: "synthetic.example.test", password: "synthetic-secret", token: 123456, secret: false };
const first = service.redact("chat", source);
assert.deepEqual(service.redact("chat", first), first);
assert.deepEqual(service.redact("chat", { text: first.token, nested: [first.secret, first.host] }),
{ text: first.token, nested: [first.secret, first.host] });
assert.equal(service.redact("chat", first.token), first.token);
assert.equal(service.restore("chat", first.token), 123456);
assert.equal(service.restore("chat", first.secret), false);
assert.deepEqual(service.restore("chat", first), source);
const later = service.redact("chat", `${first.host} with synthetic-secret and 1.1.1.1`);
assert.equal(later.includes(first.host), true);
assert.equal(later.includes(first.password), true);
assert.equal(later.includes("1.1.1.1"), false);
assert.equal(service.restore("chat", later), "synthetic.example.test with synthetic-secret and 1.1.1.1");
});
test("unknown, malformed, cross-chat and modified aliases fail closed, including preserved keys", () => {
const service = enabledService();
const token = service.redact("chat", "8.8.8.8");
for (const value of [
"[NC_literal]", "[NC_unclosed", token.replace("_IP_", "_HOST_"),
{ "[NC_unknown]": "ordinary" }, { text: "[NC_unknown]" },
]) {
assert.throws(() => service.redact("chat", value), { message: "Unknown AI privacy token; start a new chat" });
assert.throws(() => service.restore("chat", value), { message: "Unknown AI privacy token; start a new chat" });
}
assert.throws(() => service.restore("unseen", token), NEW_CHAT);
assert.equal(service.hasSession("unseen"), false);
});
test("semantic root control keys stay intact while nested user-data keys are protected", () => {
const service = enabledService();
const source = {
stdout: "password=stdout\n8.8.8.8", stderr: "", exitCode: 0,
details: { stdout: "nested key", "8.8.8.8": "address key" },
};
const masked = service.redact("chat", source);
assert.deepEqual(Object.keys(masked), Object.keys(source));
assert.equal(Object.hasOwn(masked.details, "stdout"), false);
assert.equal(Object.hasOwn(masked.details, "8.8.8.8"), false);
assert.deepEqual(service.redact("chat", masked), masked);
assert.deepEqual(service.restore("chat", masked), source);
assert.equal(source.stdout, "password=stdout\n8.8.8.8");
});
test("hostnames are remembered across earlier fields, later responses and model boundaries", () => {
const service = enabledService();
const masked = service.redact("chat", { text: "db responded", host: "db" });
assert.equal(masked.text, `${masked.host} responded`);
assert.equal(service.redact("chat", "ssh db; adb dbx db.internal"), `ssh ${masked.host}; adb dbx db.internal`);
assert.equal(service.redact("chat", masked.text), masked.text);
assert.equal(service.restore("chat", masked.text), "db responded");
});
test("equivalent configuration preserves maps, while any policy change retires active chats", () => {
const service = enabledService();
const token = service.redact("chat", "8.8.8.8");
service.configure({ patterns: [], enabled: true, cidrs: [], redactPrivateIps: false });
assert.equal(service.hasSession("chat"), true);
assert.equal(service.redact("chat", token), token);
service.configure({ enabled: false });
assert.equal(service.hasSession("chat"), false);
assert.throws(() => service.checkSession("chat"), NEW_CHAT);
assert.throws(() => service.redact("chat", "8.8.8.8"), NEW_CHAT);
assert.throws(() => service.restore("chat", token), NEW_CHAT);
service.configure({ enabled: true });
assert.throws(() => service.redact("chat", "8.8.8.8"), NEW_CHAT);
assert.notEqual(service.redact("new-chat", "8.8.8.8"), token);
service.configure({ enabled: true, redactPrivateIps: true });
assert.throws(() => service.checkSession("new-chat"), NEW_CHAT);
});
test("disabled chats are also pinned and cannot silently switch policy midway through history", () => {
const service = createAiPrivacyService();
service.checkSession("disabled");
service.configure({ enabled: true });
assert.throws(() => service.redact("disabled", "8.8.8.8"), NEW_CHAT);
assert.match(service.redact("fresh", "8.8.8.8"), TOKEN);
});
test("invalid or explicitly invalidated configuration blocks all paths until valid recovery", () => {
const service = enabledService();
const token = service.redact("chat", "8.8.8.8");
assert.throws(() => service.configure({ enabled: "sensitive-invalid-input" }),
{ message: "Invalid AI privacy configuration" });
for (const operation of [
() => service.isEnabled(), () => service.getConfig(), () => service.checkSession("chat"),
() => service.redact("chat", "8.8.8.8"), () => service.restore("chat", token),
]) assert.throws(operation, { message: "Invalid AI privacy configuration" });
assert.equal(service.hasSession("chat"), false);
service.configure({ enabled: true });
assert.throws(() => service.checkSession("chat"), NEW_CHAT);
assert.match(service.redact("recovered", "8.8.8.8"), TOKEN);
service.invalidateConfiguration();
assert.throws(() => service.isEnabled(), /Invalid AI privacy configuration/);
service.configure({});
assert.equal(service.isEnabled(), false);
assert.throws(() => service.redact("recovered", "text"), NEW_CHAT);
});
test("cleanup removes maps, is idempotent and rejects late callbacks even for unseen chats", () => {
const service = enabledService();
const token = service.redact("chat", "8.8.8.8");
service.clearSession("chat");
service.clearSession("chat");
assert.equal(service.hasSession("chat"), false);
assert.throws(() => service.checkSession("chat"), NEW_CHAT);
assert.throws(() => service.redact("chat", "8.8.8.8"), NEW_CHAT);
assert.throws(() => service.restore("chat", token), NEW_CHAT);
service.clearSession("not-started");
assert.throws(() => service.redact("not-started", "8.8.8.8"), NEW_CHAT);
assert.match(service.redact("fresh", "8.8.8.8"), TOKEN);
});
test("enabled alias maps are bounded without evicting live maps or retired IDs", () => {
const service = enabledService({}, { maxSessions: 2 });
const first = service.redact("one", "8.8.8.8");
const second = service.redact("two", "1.1.1.1");
assert.throws(() => service.redact("three", "9.9.9.9"), /session limit reached/);
assert.equal(service.redact("one", first), first);
service.clearSession("one");
assert.throws(() => service.redact("one", "8.8.8.8"), NEW_CHAT);
service.redact("three", "9.9.9.9");
service.clearSession("three");
assert.throws(() => service.redact("three", "9.9.9.9"), NEW_CHAT);
assert.match(service.redact("four", "9.9.9.9"), TOKEN);
assert.equal(service.restore("two", second), "1.1.1.1");
assert.throws(() => service.redact("one", first), NEW_CHAT);
});
test("default-off chats have no enabled-map or cleanup cap and still reject stale callbacks", () => {
const service = createAiPrivacyService({ maxSessions: 1 });
const source = { password: "synthetic-ordinary-value" };
for (let index = 0; index < 4100; index++) {
const id = `disabled-${index}`;
assert.equal(service.checkSession(id), false);
assert.equal(service.redact(id, source), source);
assert.equal(service.restore(id, source), source);
}
for (let index = 0; index < 4100; index++) service.clearSession(`disabled-${index}`);
assert.equal(service.checkSession("still-disabled"), false);
assert.equal(service.redact("still-disabled", source), source);
assert.throws(() => service.redact("disabled-0", source), NEW_CHAT);
assert.throws(() => service.redact("disabled-4099", source), NEW_CHAT);
service.configure({ enabled: true });
assert.throws(() => service.redact("still-disabled", source), NEW_CHAT);
assert.match(service.redact("new-enabled", "8.8.8.8"), TOKEN);
assert.throws(() => service.redact("enabled-over-limit", "1.1.1.1"), /session limit reached/);
});
test("turning privacy off restores unlimited new chats without reviving protected maps", () => {
const service = enabledService({}, { maxSessions: 1 });
const token = service.redact("protected", "8.8.8.8");
service.configure({ enabled: false });
for (let index = 0; index < 70; index++) {
assert.equal(service.checkSession(`off-${index}`), false);
assert.equal(service.redact(`off-${index}`, "8.8.8.8"), "8.8.8.8");
}
assert.throws(() => service.restore("protected", token), NEW_CHAT);
assert.throws(() => service.redact("protected", "8.8.8.8"), NEW_CHAT);
service.configure({ enabled: true });
assert.throws(() => service.redact("off-69", "8.8.8.8"), NEW_CHAT);
assert.match(service.redact("fresh-protected", "8.8.8.8"), TOKEN);
});
test("IDs, unsupported values and capacity errors are sanitized without leaking input data", () => {
const service = enabledService();
for (const id of [undefined, null, {}, "", " ", "x".repeat(513), "unsafe\0id"]) {
assert.throws(() => service.redact(id, "sensitive-source"),
{ message: "AI privacy requires a valid chat session" });
}
for (const value of [Buffer.from("sensitive-source"), { type: "image", data: "sensitive-source" }, "text\0binary"]) {
assert.throws(() => service.redact("chat", value),
{ message: "AI privacy could not safely process this value" });
}
for (const limits of [{ maxSessions: 0 }, { maxSessions: Number.NaN }, { initialized: "false" }]) {
assert.throws(() => createAiPrivacyService(limits), /Invalid AI privacy configuration/);
}
});
test("external execution guards reject policy changes even when settings change back", () => {
const service = createAiPrivacyService();
service.configure({ enabled: true });
const guard = service.createPolicyGuard();
guard();
service.configure({ enabled: true });
guard();
service.configure({ enabled: false });
service.configure({ enabled: true });
assert.throws(guard, /settings changed/);
const current = service.createPolicyGuard();
service.invalidateConfiguration();
assert.throws(current, /Invalid AI privacy/);
});
@@ -0,0 +1,36 @@
"use strict";
/** Managed MCP and Skills CLI share their chat's prompt mapping in the main process. */
function createManagedPrivacyBoundary(dispatch, privacyService) {
return async (method, params) => {
const chatSessionId = params?.chatSessionId;
if (!chatSessionId) {
if (privacyService.isEnabled()) {
throw new Error("AI privacy requires a managed chat session.");
}
return dispatch(method, params, { beforeExecute: () => {
if (privacyService.isEnabled()) throw new Error("AI privacy requires a managed chat session.");
} });
}
const enabled = privacyService.checkSession(chatSessionId);
const restored = enabled ? privacyService.restore(chatSessionId, params) : params;
let result;
try {
result = await dispatch(method, restored, {
beforeExecute: () => privacyService.checkSession(chatSessionId),
});
} catch (error) {
privacyService.checkSession(chatSessionId);
if (!enabled) throw error;
throw new Error(privacyService.redact(chatSessionId, String(error?.message || "Netcatty tool failed.")));
}
privacyService.checkSession(chatSessionId);
if (enabled && method === "netcatty/readAttachment" && typeof result?.text === "string") {
const { base64Data: _encoded, ...textPayload } = result;
result = textPayload;
}
return enabled ? privacyService.redact(chatSessionId, result) : result;
};
}
module.exports = { createManagedPrivacyBoundary };
@@ -0,0 +1,47 @@
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { createAiPrivacyService } = require("./aiPrivacyService.cjs");
const { createManagedPrivacyBoundary } = require("./managedPrivacyBoundary.cjs");
test("managed prompt, MCP and CLI share a chat map, restoring before dispatch", async () => {
const privacy = createAiPrivacyService();
privacy.configure({ enabled: true });
const prompt = privacy.redact("chat-one", "Investigate address=192.0.2.12");
const token = prompt.match(/\[NC_[^\]]+\]/)[0];
const calls = [];
const rpc = createManagedPrivacyBoundary(async (method, params) => {
calls.push({ method, params });
return { ok: true, stdout: params.command || "address=192.0.2.12" };
}, privacy);
const result = await rpc("netcatty/exec", { chatSessionId: "chat-one", command: `echo ${token}` });
assert.equal(calls[0].params.command, "echo 192.0.2.12");
assert.equal(result.stdout, `echo ${token}`);
assert.equal((await rpc("netcatty/read", { chatSessionId: "chat-one" })).stdout, `address=${token}`);
await assert.rejects(rpc("netcatty/exec", { chatSessionId: "chat-two", command: `echo ${token}` }));
assert.equal(calls.length, 2);
});
test("managed privacy fails closed on missing scope, changed policy, errors and binary content", async () => {
const privacy = createAiPrivacyService();
privacy.configure({ enabled: true });
const errorRpc = createManagedPrivacyBoundary(async () => { throw new Error("address=192.0.2.1"); }, privacy);
await assert.rejects(errorRpc("read", {}), /chat session/);
await assert.rejects(errorRpc("read", { chatSessionId: "chat" }), (error) => !error.message.includes("192.0.2.1"));
const binaryRpc = createManagedPrivacyBoundary(async () => ({ base64Data: "YmluYXJ5" }), privacy);
await assert.rejects(binaryRpc("read", { chatSessionId: "chat" }));
const textRpc = createManagedPrivacyBoundary(async () => ({ text: "192.0.2.1", base64Data: "duplicate" }), privacy);
const result = await textRpc("netcatty/readAttachment", { chatSessionId: "chat" });
assert.equal(result.base64Data, undefined);
assert.doesNotMatch(result.text, /192\.0\.2\.1/);
privacy.configure({ enabled: false });
await assert.rejects(textRpc("read", { chatSessionId: "chat" }));
});
test("disabled managed privacy preserves ordinary unscoped CLI calls", async () => {
const privacy = createAiPrivacyService();
const result = { ok: true, stdout: "192.0.2.1" };
const rpc = createManagedPrivacyBoundary(async () => result, privacy);
assert.equal(await rpc("read", {}), result);
});
+31
View File
@@ -22,6 +22,25 @@ const DEFAULT_BACKGROUND_JOB_TIMEOUT_MS = 60 * 60 * 1000;
const DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS = 30 * 1000;
const BACKGROUND_JOB_RETENTION_MS = 10 * 60 * 1000;
const MAX_BACKGROUND_JOB_OUTPUT_CHARS = 256 * 1024;
const EXECUTION_GUARD_ERROR = "AI privacy session is unavailable or its settings changed; start a new chat";
async function checkWorkerExecution(event, activePtyExecs, chatSessionId) {
if (typeof event?.beforeExecute !== "function") return null;
const marker = `__NCMCP_VALIDATE_${crypto.randomBytes(8).toString("hex")}__`;
let cancelled = false;
activePtyExecs?.set(marker, {
chatSessionId,
cancel: () => { cancelled = true; },
});
try {
await event.beforeExecute();
return cancelled ? { ok: false, error: "Cancelled" } : null;
} catch {
return { ok: false, error: EXECUTION_GUARD_ERROR };
} finally {
activePtyExecs?.delete(marker);
}
}
function cancelPtyExecsForSession(activePtyExecs, chatSessionId) {
if (!chatSessionId) return;
@@ -260,6 +279,8 @@ function createWorkerAiExecHandler({
const ptyStream = session.stream || session.pty || session.proc;
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
const blocked = await checkWorkerExecution(event, activePtyExecs, chatSessionId);
if (blocked) return blocked;
return execViaRawPty(ptyStream, command, {
timeoutMs,
trackForCancellation: activePtyExecs,
@@ -285,6 +306,8 @@ function createWorkerAiExecHandler({
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const blocked = await checkWorkerExecution(event, activePtyExecs, chatSessionId);
if (blocked) return blocked;
return execViaPty(ptyStream, command, {
onInterrupt: () => clearSessionFlowState(session),
stripMarkers: true,
@@ -340,6 +363,8 @@ function createWorkerAiExecHandler({
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const blocked = await checkWorkerExecution(event, activePtyExecs, chatSessionId);
if (blocked) return blocked;
return execViaChannel(sshClient, command, {
timeoutMs,
trackForCancellation: activePtyExecs,
@@ -351,6 +376,8 @@ function createWorkerAiExecHandler({
if (session.ymodemActive || session.zmodemSentry?.isActive?.()) {
return { ok: false, error: "Serial file transfer is already in progress" };
}
const blocked = await checkWorkerExecution(event, activePtyExecs, chatSessionId);
if (blocked) return blocked;
return execViaRawPty(session.serialPort, command, {
timeoutMs,
trackForCancellation: activePtyExecs,
@@ -454,6 +481,10 @@ function createWorkerAiJobStartHandler({
// reserved above so concurrent starts cannot pass the busy check.
try {
await ensureSessionShellKind(session);
// Main retains the policy snapshot. Revalidate only after the worker's
// async probe, not just before main handed this command to the worker.
const blocked = await checkWorkerExecution(event, null, chatSessionId);
if (blocked) throw new Error(blocked.error);
} catch (err) {
job.status = "failed";
job.error = err?.message || String(err);
+31
View File
@@ -78,6 +78,37 @@ function nextTick() {
return new Promise((resolve) => setImmediate(resolve));
}
for (const channel of ["netcatty:ai:exec", "netcatty:ai:jobStart"]) {
test(`${channel} stays cancellable while main validates execution`, async () => {
const pty = new FakePty();
const ipcMain = createFakeIpcMain();
registerWorkerAiExecHandlers(ipcMain, {
sessions: new Map([["session", { protocol: "ssh", stream: pty, shellKind: "posix" }]]),
});
let validationEntered;
let allowExecution;
const entered = new Promise(resolve => { validationEntered = resolve; });
const validation = new Promise(resolve => { allowExecution = resolve; });
const event = {
...createFakeEvent(),
beforeExecute: async () => {
validationEntered();
await validation;
},
};
const pending = ipcMain.handlers.get(channel)(event, {
sessionId: "session", command: "echo synthetic", chatSessionId: "chat-validation",
});
await entered;
ipcMain.listeners.get("netcatty:ai:catty:cancel")(event, { chatSessionId: "chat-validation" });
allowExecution();
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.error, "Cancelled");
assert.deepEqual(pty.writes, []);
});
}
test("worker job stop receives cancellation output while renderer flow is paused", async () => {
class PausablePty extends Duplex {
constructor() { super(); this.writes = []; }
+33
View File
@@ -355,6 +355,32 @@ function createTerminalWorkerRuntime(options = {}) {
? options.reportSuppressedError
: null;
const ipcMain = createIpcMainHarness();
const executionValidations = new Map();
let executionValidationCounter = 0;
const executionValidationTimeoutMs = options.executionValidationTimeoutMs ?? 10_000;
const executionValidationError = "AI privacy session is unavailable or its settings changed; start a new chat";
function validateExecution(message) {
return new Promise((resolve, reject) => {
const validationId = `${message.requestId}:${++executionValidationCounter}`;
const finish = (ok) => {
const entry = executionValidations.get(validationId);
if (!entry) return;
executionValidations.delete(validationId);
clearTimeout(entry.timer);
if (ok) resolve();
else reject(new Error(executionValidationError));
};
const timer = setTimeout(() => finish(false), executionValidationTimeoutMs);
timer.unref?.();
executionValidations.set(validationId, { requestId: message.requestId, timer, finish });
try {
parentPort.postMessage({ kind: "validate-execution", requestId: message.requestId, validationId });
} catch {
finish(false);
}
});
}
let started = false;
const outputPorts = createOutputPortRegistry();
const pendingOutputBySession = new Map();
@@ -504,6 +530,8 @@ function createTerminalWorkerRuntime(options = {}) {
sessionLifecycleTombstoneTimes.delete(requestedSessionId);
}
const result = await handler({
...(message.requiresExecutionValidation === true
? { beforeExecute: () => validateExecution(message) } : {}),
sender: createSender(
parentPort,
message.webContentsId,
@@ -751,6 +779,11 @@ function createTerminalWorkerRuntime(options = {}) {
function handleMessage(eventOrMessage) {
const { message, ports } = normalizeMessageEvent(eventOrMessage);
if (message?.kind === "execution-validation") {
const entry = executionValidations.get(message.validationId);
if (entry?.requestId === message.requestId) entry.finish(message.ok === true);
return;
}
if (message?.kind === "set-ssh-transport-idle-ttl") {
setDefaultTransportIdleTtlMs(message.value);
return;
+60
View File
@@ -82,6 +82,66 @@ test("runtime invokes registered request handlers and posts responses", async ()
]);
});
test("execution validation is request scoped and denial prevents worker effects", async () => {
const parentPort = createParentPort();
let effects = 0;
const runtime = createTerminalWorkerRuntime({
parentPort,
registerBridges(ipcMain) {
ipcMain.handle("netcatty:ai:exec", async event => {
await event.beforeExecute();
effects++;
return { ok: true };
});
},
});
runtime.start();
parentPort.emitMessage({
kind: "request", requestId: "guarded", channel: "netcatty:ai:exec",
requiresExecutionValidation: true,
});
await new Promise(resolve => setImmediate(resolve));
const validation = parentPort.messages.find(message => message.kind === "validate-execution");
assert.ok(validation);
parentPort.emitMessage({
kind: "execution-validation", requestId: "another-request",
validationId: validation.validationId, ok: true,
});
await new Promise(resolve => setImmediate(resolve));
assert.equal(effects, 0);
assert.equal(parentPort.messages.some(message => message.kind === "response"), false);
parentPort.emitMessage({
kind: "execution-validation", requestId: "guarded",
validationId: validation.validationId, ok: false,
});
await new Promise(resolve => setImmediate(resolve));
assert.equal(effects, 0);
assert.match(parentPort.messages.at(-1).error, /privacy session is unavailable/);
});
test("execution validation times out closed without a main response", async () => {
const parentPort = createParentPort();
let effects = 0;
const runtime = createTerminalWorkerRuntime({
parentPort,
executionValidationTimeoutMs: 10,
registerBridges(ipcMain) {
ipcMain.handle("netcatty:ai:exec", async event => {
await event.beforeExecute();
effects++;
});
},
});
runtime.start();
parentPort.emitMessage({
kind: "request", requestId: "timeout", channel: "netcatty:ai:exec",
requiresExecutionValidation: true,
});
await new Promise(resolve => setTimeout(resolve, 30));
assert.equal(effects, 0);
assert.match(parentPort.messages.at(-1).error, /privacy session is unavailable/);
});
test("runtime serializes overlapping same-id starts and closes the superseded session first", async () => {
const parentPort = createParentPort();
const order = [];
+5 -5
View File
@@ -202,10 +202,10 @@ export async function prepareContextCompaction({
};
}
export function formatMessagesForCompaction(messages: ModelMessage[]): string {
export function formatMessagesForCompaction(messages: ModelMessage[], chatSessionId?: string): string {
return messages
.map((message, index) => {
return `<message index="${index + 1}" role="${escapeXml(String(message.role))}">\n${escapeXml(formatMessageContent(message.content))}\n</message>`;
return `<message index="${index + 1}" role="${escapeXml(String(message.role))}">\n${escapeXml(formatMessageContent(message.content, chatSessionId))}\n</message>`;
})
.join("\n\n");
}
@@ -233,9 +233,9 @@ function endsWithToolCall(message: ModelMessage | undefined): boolean {
});
}
function formatMessageContent(content: ModelMessage["content"]): string {
if (typeof content === "string") return redactSecretsForModel(content);
return redactSecretsForModel(JSON.stringify(sanitizeContentForCompaction(content), null, 2));
function formatMessageContent(content: ModelMessage["content"], chatSessionId?: string): string {
if (typeof content === "string") return redactSecretsForModel(content, chatSessionId);
return redactSecretsForModel(JSON.stringify(sanitizeContentForCompaction(content), null, 2), chatSessionId);
}
function sanitizeContentForCompaction(content: Exclude<ModelMessage["content"], string>): unknown {
+6 -3
View File
@@ -12,8 +12,9 @@ import type {
TurnSteerResult,
} from './turnDrivers/types';
import { globalTwoPassCompactionCache } from './twoPassCompaction';
import { redactSecretsForModel, redactSecretsInValueForModel } from './modelSecretRedaction';
import { redactSecretsForModel, redactSecretsInValueForModel, setReversibleModelPrivacy } from './modelSecretRedaction';
import { globalTerminalMonitorGuard } from './terminalMonitorGuard';
import { netcattyBridge } from '../../services/netcattyBridge';
let turnCounter = 0;
@@ -70,6 +71,8 @@ export class AgentRuntime {
}
clearChatSession(chatSessionId: string): void {
setReversibleModelPrivacy(chatSessionId, false);
void netcattyBridge.get()?.aiPrivacyClearSession?.(chatSessionId).catch(() => {});
this.toolOutputStore.prune(chatSessionId);
this.sessionStateStore.clear(chatSessionId);
this.traceStore.clear(chatSessionId);
@@ -154,14 +157,14 @@ export class AgentRuntime {
if (event.type === 'tool_result') {
event = {
...event,
result: redactSecretsForModel(event.result),
result: redactSecretsForModel(event.result, input.backend === 'catty' ? chatSessionId : undefined),
};
}
if (event.type === 'tool_call') {
event = {
...event,
args: redactSecretsInValueForModel(event.args),
args: redactSecretsInValueForModel(event.args, input.backend === 'catty' ? chatSessionId : undefined),
};
toolCallMeta.set(event.toolCallId, {
toolName: event.toolName,
+5 -1
View File
@@ -18,6 +18,7 @@ import { reserveSessionSlot } from '../shared/sessionExecutionQueue';
import { fitTerminalExecuteResultForModel } from './terminalCompression';
import { fitLargeToolResultForModel } from './toolResultFitting';
import { redactSecretsForModel } from './modelSecretRedaction';
import { assertModelPrivacySession, type ModelPrivacyBridge } from './modelPrivacy';
import {
globalTerminalMonitorGuard,
isStreamingMonitorCommand,
@@ -239,7 +240,7 @@ async function executeLocalCattyCapability(ctx: LocalExecutionContext): Promise<
if (result == null) {
return { error: `Handle "${handleId}" was not found for this chat session.` };
}
return { ...result, content: redactSecretsForModel(result.content) };
return { ...result, content: redactSecretsForModel(result.content, chatSessionId) };
}
case 'harness.workspace.get_info': {
const scopeCtx = resolveContext();
@@ -411,6 +412,9 @@ function createCatalogTool(spec: CattyToolSpec) {
}
await slot?.ready;
// SDK approval precedes execute; the policy can change while the user
// decides or while this call waits behind another terminal operation.
await assertModelPrivacySession(deps.bridge as ModelPrivacyBridge, deps.chatSessionId);
if (spec.capabilityId === 'terminal.execute') {
const { sessionId: sid, command } = args as { sessionId: string; command: string };
+4 -4
View File
@@ -110,7 +110,7 @@ export async function compactCattyMessages(
instructions: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Create the first-pass note for this stable earlier prefix. Preserve exact decisions, paths, commands, errors, and unfinished work:\n\n${formatMessagesForCompaction(prefix)}`,
content: `Create the first-pass note for this stable earlier prefix. Preserve exact decisions, paths, commands, errors, and unfinished work:\n\n${formatMessagesForCompaction(prefix, input.chatSessionId)}`,
}],
abortSignal: input.abortSignal,
maxOutputTokens: COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS,
@@ -151,13 +151,13 @@ export async function compactCattyMessages(
const formattedHistory = cached
? [
`[FIRST-PASS NOTE FOR ${cached.prefixLength} EARLIER MESSAGES]\n${cached.note}`,
`[REMAINING EXACT MESSAGES]\n${formatMessagesForCompaction(pruned.slice(cached.prefixLength))}`,
`[REMAINING EXACT MESSAGES]\n${formatMessagesForCompaction(pruned.slice(cached.prefixLength), input.chatSessionId)}`,
].join('\n\n')
: formatMessagesForCompaction(pruned);
: formatMessagesForCompaction(pruned, input.chatSessionId);
// Archive the untouched turn input. messagesToSummarize has already passed
// through integrity repair and stale-result pruning, so it is not an exact
// recovery source even though it is the right input for the summary model.
const exactFormattedHistory = formatMessagesForCompaction(input.messages);
const exactFormattedHistory = formatMessagesForCompaction(input.messages, input.chatSessionId);
archiveChars = exactFormattedHistory.length;
if (input.toolOutputStore && input.chatSessionId) {
const archive = storeCompactionArchive(
@@ -206,6 +206,7 @@ export async function prepareTurnContext(
});
const stale = pruneStaleToolContext(integrity.messages, {
underBudgetPressure,
chatSessionId: input.chatSessionId,
});
let working = stale.messages;
let didAdjust = integrity.didAdjust || stale.didAdjust;
@@ -417,6 +418,7 @@ export async function prepareStepContext(
});
const stale = pruneStaleToolContext(integrity.messages, {
underBudgetPressure,
chatSessionId: input.chatSessionId,
});
let working = stale.messages;
@@ -0,0 +1,618 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import test from 'node:test';
import { generateText, isStepCount, streamText, tool, wrapLanguageModel } from 'ai';
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
import type { LanguageModelV4CallOptions, LanguageModelV4StreamPart, LanguageModelV4StreamResult } from '@ai-sdk/provider';
import { z } from 'zod';
import {
createModelPrivacyMiddleware,
initializeModelPrivacy,
protectModel,
redactOpenAIChatAssistantFields,
redactModelPrompt,
type ModelPrivacyBridge,
} from './modelPrivacy';
import { redactSecretsForModel, setReversibleModelPrivacy } from './modelSecretRedaction';
import { fitTerminalExecuteResultForModel } from './terminalCompression';
import { fitLargeToolResultForModel } from './toolResultFitting';
import { formatMessagesForCompaction } from '../contextCompaction';
import { pruneStaleToolContext } from './staleContextPruner';
import { compactCattyMessages } from './cattyRuntime';
import { createBridgeFetchForSDK, createModelFromConfig } from '../sdk/providers';
import { getOpenAIChatAssistantFieldsForHistoryMessage } from '../providerContinuation';
import { createCattyToolsFromCatalog } from './capabilityTools';
import { buildCattyToolApproval } from './cattyToolApproval';
import { reserveSessionSlot } from '../shared/sessionExecutionQueue';
const require = createRequire(import.meta.url);
const { createAiPrivacyService } = require('../../../electron/shared/aiPrivacyService.cjs');
const ADDRESS = '203.0.113.19';
const usage = {
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 1, text: 1, reasoning: undefined },
};
async function fixture(t: test.TestContext, chatSessionId = t.name) {
const service = createAiPrivacyService();
service.configure({ enabled: true });
const bridge: ModelPrivacyBridge = {
aiPrivacyStatus: async () => ({ ok: true, enabled: service.isEnabled() }),
aiPrivacyTransform: async ({ operation, value, chatSessionId: id }) => {
try {
return { ok: true, value: service[operation](id, value) };
} catch (error) {
return { ok: false, error: String(error) };
}
},
};
t.after(() => setReversibleModelPrivacy(chatSessionId, false));
const privacy = await initializeModelPrivacy(bridge, chatSessionId);
assert.ok(privacy);
return { privacy, service, bridge, chatSessionId };
}
function stream(chunks: LanguageModelV4StreamPart[]): LanguageModelV4StreamResult {
return {
stream: simulateReadableStream<LanguageModelV4StreamPart>({
chunks: [
{ type: 'stream-start', warnings: [] },
...chunks,
{ type: 'finish', finishReason: { unified: 'stop', raw: undefined }, usage },
],
initialDelayInMs: null,
chunkDelayInMs: null,
}),
};
}
async function drain(source: ReadableStream<LanguageModelV4StreamPart>) {
const reader = source.getReader();
const chunks: LanguageModelV4StreamPart[] = [];
try {
while (true) {
const { value, done } = await reader.read();
if (done) return chunks;
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}
test('privacy off and old bridge mocks retain the existing behavior', async () => {
const model = new MockLanguageModelV4();
assert.equal(protectModel(model), model);
assert.equal(await initializeModelPrivacy({}, 'old-bridge'), undefined);
const disabled = await initializeModelPrivacy({
aiPrivacyStatus: async () => ({ ok: true, enabled: false }),
aiPrivacyTransform: async ({ value }) => ({ ok: true, value }),
}, 'disabled');
assert.equal(disabled?.enabled, false);
assert.equal(redactSecretsForModel('password=private', 'disabled'), 'password=[REDACTED]');
});
test('status and transformation errors fail closed without provider calls', async (t) => {
await assert.rejects(initializeModelPrivacy({
aiPrivacyStatus: async () => ({ ok: false, enabled: false, error: 'failed' }),
}, 'denied'), /privacy protection is unavailable/);
const { bridge, chatSessionId } = await fixture(t);
const privacy = await initializeModelPrivacy(bridge, chatSessionId);
bridge.aiPrivacyTransform = async () => ({ ok: false, error: 'synthetic failure' });
const model = new MockLanguageModelV4();
await assert.rejects(async () => protectModel(model, privacy).doGenerate({
prompt: [{ role: 'user', content: [{ type: 'text', text: ADDRESS }] }],
}), /privacy protection is unavailable/);
assert.equal(model.doGenerateCalls.length, 0);
});
test('model boundary masks semantic content while leaving protocol and opaque fields intact', async (t) => {
const { privacy } = await fixture(t);
const opaque = { anthropic: { signature: 'opaque-signature' } };
const params: LanguageModelV4CallOptions = {
prompt: [
{ role: 'system', content: `hostname: ${ADDRESS}` },
{ role: 'user', content: [{ type: 'text', text: `password=synth-secret; inspect ${ADDRESS}` }] },
{ role: 'assistant', content: [
{ type: 'reasoning', text: 'safe signed reasoning', providerOptions: opaque },
{ type: 'tool-call', toolCallId: 'call-1', toolName: 'inspect', input: { target: ADDRESS, password: 'synth-secret' } },
] },
{ role: 'tool', content: [{
type: 'tool-result', toolCallId: 'call-1', toolName: 'inspect',
output: { type: 'json', value: { address: ADDRESS, output: 'synth-secret' } },
}] },
],
providerOptions: { example: { opaqueValue: ADDRESS } },
};
const result = await redactModelPrompt(params, privacy);
assert.doesNotMatch(JSON.stringify(result.prompt), /203\.0\.113\.19|synth-secret/);
assert.match(JSON.stringify(result.prompt), /NC_.*IP_/);
assert.match(JSON.stringify(result.prompt), /"toolCallId":"call-1","toolName":"inspect"/);
assert.equal(result.providerOptions, params.providerOptions);
assert.equal((result.prompt[2].content[0] as { providerOptions?: unknown }).providerOptions, opaque);
assert.match(JSON.stringify(params.prompt), /synth-secret/);
});
test('signed private reasoning is rejected instead of being modified or leaked', async (t) => {
const { privacy } = await fixture(t);
await assert.rejects(redactModelPrompt({
prompt: [{ role: 'assistant', content: [{
type: 'reasoning',
text: `Connecting to ${ADDRESS}`,
providerOptions: { anthropic: { signature: 'signed-before-privacy' } },
}] }],
}, privacy), /Start a new chat/);
});
test('UTF-8 text attachments are redacted and binary attachments fail closed', async (t) => {
const { privacy } = await fixture(t);
const result = await redactModelPrompt({
prompt: [{ role: 'user', content: [{
type: 'file',
filename: 'hosts.txt',
mediaType: 'text/plain',
data: { type: 'data', data: Buffer.from(`hostname: ${ADDRESS}`).toString('base64') },
}] }],
}, privacy);
const part = result.prompt[0].content[0] as { data: { data: Uint8Array } };
assert.doesNotMatch(new TextDecoder().decode(part.data.data), /203\.0\.113\.19/);
assert.match(new TextDecoder().decode(part.data.data), /NC_/);
await assert.rejects(redactModelPrompt({
prompt: [{ role: 'user', content: [{
type: 'file', mediaType: 'image/png', data: { type: 'data', data: 'AA==' },
}] }],
}, privacy), /cannot inspect this attachment/);
});
test('attachment tool results and history omit the encoded duplicate before redaction', async (t) => {
const { privacy } = await fixture(t);
const payload = { text: `hostname: ${ADDRESS}`, base64Data: Buffer.from(ADDRESS).toString('base64') };
for (const output of [
{ type: 'json' as const, value: payload },
{ type: 'text' as const, value: JSON.stringify(payload) },
]) {
const result = await redactModelPrompt({
prompt: [{ role: 'tool', content: [{
type: 'tool-result', toolName: 'read_attachment', toolCallId: 'attachment', output,
}] }],
}, privacy);
assert.doesNotMatch(JSON.stringify(result.prompt), /base64Data|203\.0\.113\.19/);
assert.match(JSON.stringify(result.prompt), /NC_/);
}
await assert.rejects(redactModelPrompt({
prompt: [{ role: 'tool', content: [{
type: 'tool-result', toolName: 'read_attachment', toolCallId: 'image',
output: { type: 'json', value: { base64Data: 'AA==', mediaType: 'image/png' } },
}] }],
}, privacy), /cannot inspect this attachment/);
assert.ok(payload.base64Data);
});
test('stream restores tokens split at every character and flushes an ordinary bracket suffix', async (t) => {
const { privacy } = await fixture(t);
const token = await privacy.transform('redact', ADDRESS);
const model = new MockLanguageModelV4({
doStream: stream([
{ type: 'text-start', id: 'text-1' },
...[...`host=${token}; [`].map(delta => ({ type: 'text-delta' as const, id: 'text-1', delta })),
{ type: 'text-end', id: 'text-1' },
]),
});
const response = await protectModel(model, privacy).doStream({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
});
let text = '';
for (const chunk of await drain(response.stream)) {
if (chunk.type === 'text-delta') text += chunk.delta;
}
assert.equal(text, `host=${ADDRESS}; [`);
});
test('stream preserves reasoning bytes and provider continuation metadata', async (t) => {
const { privacy } = await fixture(t);
const token = await privacy.transform('redact', ADDRESS);
const metadata = { anthropic: { signature: 'opaque-signature' } };
const chunks: LanguageModelV4StreamPart[] = [
{ type: 'reasoning-start', id: 'reasoning', providerMetadata: metadata },
{ type: 'reasoning-delta', id: 'reasoning', delta: `inspect ${token}`, providerMetadata: metadata },
{ type: 'reasoning-end', id: 'reasoning', providerMetadata: metadata },
];
const model = new MockLanguageModelV4({ doStream: stream(chunks) });
const response = await protectModel(model, privacy).doStream({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
});
const actual = [];
for (const chunk of await drain(response.stream)) {
if (chunk.type.startsWith('reasoning-')) actual.push(chunk);
}
assert.deepEqual(actual, chunks);
});
test('full tool input is restored before schema validation, approval and execution', async (t) => {
const { privacy } = await fixture(t);
const token = await privacy.transform('redact', ADDRESS);
let requests = 0;
let approved = false;
let executed = false;
const model = new MockLanguageModelV4({
doStream: async (params) => {
requests++;
assert.doesNotMatch(JSON.stringify(params.prompt), /203\.0\.113\.19|synth-result-secret/);
if (requests === 1) {
return {
stream: simulateReadableStream<LanguageModelV4StreamPart>({
chunks: [
{ type: 'stream-start', warnings: [] },
{ type: 'tool-call', toolCallId: 'call-1', toolName: 'inspect', input: JSON.stringify({ target: token }) },
{ type: 'finish', finishReason: { unified: 'tool-calls', raw: undefined }, usage },
],
initialDelayInMs: null,
chunkDelayInMs: null,
}),
};
}
assert.match(JSON.stringify(params.prompt), /SECRET_/);
return stream([
{ type: 'text-start', id: 'answer' },
{ type: 'text-delta', id: 'answer', delta: `Checked ${token}` },
{ type: 'text-end', id: 'answer' },
]);
},
});
const result = streamText({
model: protectModel(model, privacy),
messages: [{ role: 'user', content: `Inspect ${ADDRESS}` }],
tools: {
inspect: tool({
inputSchema: z.object({ target: z.literal(ADDRESS) }),
execute: async ({ target }) => {
assert.equal(target, ADDRESS);
assert.equal(approved, true);
executed = true;
return { password: 'synth-result-secret' };
},
}),
},
toolApproval: async ({ toolCall }) => {
assert.deepEqual(toolCall.input, { target: ADDRESS });
approved = true;
return { type: 'approved' };
},
stopWhen: isStepCount(2),
});
for await (const chunk of result.stream) {
if (chunk.type === 'error') throw chunk.error;
}
assert.equal(await result.text, `Checked ${ADDRESS}`);
assert.equal(approved, true);
assert.equal(executed, true);
assert.equal(requests, 2);
});
test('policy changes while restored tool arguments await approval prevent execution', async (t) => {
const { privacy, bridge, service, chatSessionId } = await fixture(t);
const token = await privacy.transform('redact', ADDRESS);
let calls = 0;
const bundle = createCattyToolsFromCatalog({
...bridge,
aiExec: async () => { calls++; return { ok: true }; },
aiCapability: async () => { calls++; return { ok: true }; },
}, { sessions: [] }, [], 'confirm', undefined, chatSessionId);
const catalogExecute = bundle.tools.sftp_write_file.execute as (
input: unknown, options: unknown,
) => Promise<unknown>;
let approvalEntered!: () => void;
let finishApproval!: (approved: boolean) => void;
const pendingApproval = new Promise<void>(resolve => { approvalEntered = resolve; });
const decision = new Promise<boolean>(resolve => { finishApproval = resolve; });
const approve = buildCattyToolApproval({
permissionMode: 'confirm',
chatSessionId,
requestApproval: async (_id, _name, args) => {
assert.equal(args.content, ADDRESS);
approvalEntered();
return decision;
},
});
const model = new MockLanguageModelV4({
doStream: stream([{
type: 'tool-call', toolCallId: 'write', toolName: 'sftp_write_file',
input: JSON.stringify({ sessionId: 'session', path: '/synthetic.txt', content: token }),
}]),
});
const result = streamText({
model: protectModel(model, privacy),
messages: [{ role: 'user', content: `Write ${ADDRESS}` }],
tools: {
sftp_write_file: tool({
inputSchema: z.object({ sessionId: z.string(), path: z.string(), content: z.literal(ADDRESS) }),
execute: async (args, options) => catalogExecute(args, {
...options, context: bundle.toolsContext.sftp_write_file,
}),
}),
},
toolApproval: async ({ toolCall }) => approve({ toolCall } as Parameters<typeof approve>[0]),
stopWhen: isStepCount(1),
});
const failures: string[] = [];
const consumed = (async () => {
for await (const chunk of result.stream) {
if (chunk.type === 'tool-error' || chunk.type === 'error') failures.push(String(chunk.error));
}
})();
await pendingApproval;
service.configure({ enabled: false });
finishApproval(true);
await consumed;
assert.equal(calls, 0);
assert.match(failures.join('\n'), /privacy protection is unavailable/);
});
test('queued catalog calls recheck the pinned privacy policy after their slot becomes ready', async (t) => {
const { bridge, service, chatSessionId } = await fixture(t);
let calls = 0;
const bundle = createCattyToolsFromCatalog({
...bridge,
aiExec: async () => { calls++; return { ok: true }; },
aiCapability: async () => { calls++; return { ok: true }; },
}, { sessions: [] }, [], 'auto', undefined, chatSessionId);
const slot = reserveSessionSlot(`${chatSessionId}:session`);
const catalogExecute = bundle.tools.sftp_write_file.execute as (
input: unknown, options: unknown,
) => Promise<unknown>;
const result = catalogExecute({
sessionId: 'session', path: '/synthetic.txt', content: ADDRESS,
}, {
toolCallId: 'queued-write', messages: [], context: bundle.toolsContext.sftp_write_file,
});
const rejected = assert.rejects(async () => result, /privacy protection is unavailable/);
service.configure({ enabled: false });
slot.release();
await rejected;
assert.equal(calls, 0);
});
test('generate path used by compaction redacts requests and restores returned summaries', async (t) => {
const { privacy, chatSessionId } = await fixture(t);
let requests = 0;
const model = new MockLanguageModelV4({
doGenerate: async params => {
requests++;
const serialized = JSON.stringify(params.prompt);
assert.doesNotMatch(serialized, /synth-compaction-secret/);
const token = serialized.match(/\[NC_[^\]]+_SECRET_\d+\]/)?.[0];
assert.ok(token);
return {
content: [{ type: 'text', text: `Remember ${token}` }],
finishReason: { unified: 'stop', raw: undefined },
usage,
warnings: [],
};
},
});
const compacted = await compactCattyMessages({
messages: [
{ role: 'user', content: 'password=synth-compaction-secret' },
{ role: 'assistant', content: 'Noted' },
],
sessionId: chatSessionId,
chatSessionId,
model: protectModel(model, privacy),
abortSignal: new AbortController().signal,
force: true,
trigger: 'force',
protectRecentMessages: 0,
});
assert.equal(requests, 1);
assert.equal(compacted.summary, 'Remember synth-compaction-secret');
});
test('legacy fitters and pruning defer irreversible masking only for the protected chat', async (t) => {
const { chatSessionId } = await fixture(t);
const secret = 'password=synth-secret';
const terminal = fitTerminalExecuteResultForModel({
stdout: secret, stderr: '', exitCode: 0,
}, { chatSessionId });
assert.equal(terminal.stdout, secret);
assert.equal(fitLargeToolResultForModel({
result: secret, capabilityId: 'test', chatSessionId,
}), secret);
assert.match(formatMessagesForCompaction([{ role: 'user', content: secret }], chatSessionId), /synth-secret/);
const messages = [{
role: 'assistant' as const,
content: [{ type: 'tool-call' as const, toolCallId: 'x', toolName: 'test', input: { password: 'synth-secret' } }],
}];
assert.match(JSON.stringify(pruneStaleToolContext(messages, { chatSessionId }).messages), /synth-secret/);
assert.doesNotMatch(JSON.stringify(pruneStaleToolContext(messages).messages), /synth-secret/);
assert.equal(redactSecretsForModel(secret, 'another-chat'), 'password=[REDACTED]');
});
test('unknown tool tokens cannot reach execution', async (t) => {
const { privacy } = await fixture(t);
const model = new MockLanguageModelV4({
doGenerate: {
content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'inspect', input: '{"target":"[NC_unknown_IP_1]"}' }],
finishReason: { unified: 'tool-calls', raw: undefined },
usage,
warnings: [],
},
});
let executed = false;
await assert.rejects(generateText({
model: protectModel(model, privacy),
messages: [{ role: 'user', content: ADDRESS }],
tools: {
inspect: tool({
inputSchema: z.object({ target: z.string() }),
execute: async () => { executed = true; return 'unsafe'; },
}),
},
maxRetries: 0,
}), /privacy protection is unavailable/);
assert.equal(executed, false);
});
test('OpenAI-compatible reasoning injected after model conversion is still protected', async (t) => {
const { privacy } = await fixture(t);
const mockGlobal = globalThis as unknown as { window?: unknown };
const originalWindow = mockGlobal.window;
t.after(() => {
mockGlobal.window = originalWindow;
});
let sentBody = '';
mockGlobal.window = {
netcatty: {
aiFetch: async (_url: string, _method: string, _headers: unknown, body: string) => {
sentBody = body;
return { ok: true, status: 200, data: '{}' };
},
},
};
const fields = [{ reasoning_content: `Inspect ${ADDRESS}`, signature: 'opaque-signature' }];
const fetch = createBridgeFetchForSDK('synthetic-provider', {
getOpenAIChatAssistantFields: () => fields,
transformOpenAIChatAssistantFields: values => redactOpenAIChatAssistantFields(values, privacy),
});
await fetch('https://example.test/v1/chat/completions', {
method: 'POST',
body: JSON.stringify({
messages: [
{ role: 'assistant', tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'inspect', arguments: '{}' } }] },
{ role: 'tool', tool_call_id: 'call-1', content: 'done' },
],
}),
});
assert.doesNotMatch(sentBody, /203\.0\.113\.19/);
assert.match(sentBody, /NC_/);
assert.match(sentBody, /opaque-signature/);
assert.equal(fields[0].reasoning_content, `Inspect ${ADDRESS}`);
});
test('legacy continuation secrets are discovered with each model prompt before provider conversion', async (t) => {
const { privacy } = await fixture(t);
const mockGlobal = globalThis as unknown as { window?: unknown };
const originalWindow = mockGlobal.window;
t.after(() => { mockGlobal.window = originalWindow; });
const bodies: string[] = [];
mockGlobal.window = {
netcatty: {
aiFetch: async (_url: string, _method: string, _headers: unknown, body: string) => {
bodies.push(body);
return {
ok: true, status: 200,
data: JSON.stringify({
id: 'synthetic-response', object: 'chat.completion', created: 0, model: 'synthetic',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
}),
};
},
},
};
const source = { providerConfigId: 'synthetic', providerType: 'custom', modelId: 'synthetic' };
let fields: Array<Record<string, unknown> | undefined> = [];
const getOpenAIChatAssistantFields = () => fields;
const model = protectModel(createModelFromConfig({
id: source.providerConfigId, providerId: source.providerType, name: 'Synthetic',
enabled: true, defaultModel: source.modelId, baseURL: 'https://example.test/v1', apiKey: 'synthetic-key',
}, {
getOpenAIChatAssistantFields,
transformOpenAIChatAssistantFields: values => redactOpenAIChatAssistantFields(values, privacy),
}), privacy, { getOpenAIChatAssistantFields });
for (const [host, secret] of [
['prod.example.test', 'synthetic-first-token'],
['second.example.test', 'synthetic-second-token'],
]) {
const thinking = `hostname=${host} password=${secret}`;
// Old conversations have only thinking, not stored reasoningParts.
const legacy = getOpenAIChatAssistantFieldsForHistoryMessage({
providerId: source.providerType, model: source.modelId, thinking,
}, source);
fields = [{ ...legacy, signature: 'opaque-signature' }];
await model.doGenerate({
prompt: [
{ role: 'user', content: [{ type: 'text', text: `${host} ${secret}` }] },
{ role: 'assistant', content: [{
type: 'tool-call', toolCallId: 'call', toolName: 'inspect', input: {},
}] },
{ role: 'tool', content: [{
type: 'tool-result', toolCallId: 'call', toolName: 'inspect',
output: { type: 'text', value: 'done' },
}] },
],
});
const body = bodies.at(-1)!;
assert.ok(!body.includes(host));
assert.ok(!body.includes(secret));
assert.match(body, /NC_.*HOST_/);
assert.match(body, /NC_.*SECRET_/);
assert.match(body, /reasoning_content/);
assert.match(body, /opaque-signature/);
assert.equal(fields[0]?.reasoning_content, thinking);
}
assert.equal(bodies.length, 2);
});
test('enabling privacy during an off-mode turn blocks its next model call', async (t) => {
const { bridge, service } = await fixture(t);
service.configure({ enabled: false });
const chatSessionId = 'off-mode-policy-switch';
t.after(() => setReversibleModelPrivacy(chatSessionId, false));
const privacy = await initializeModelPrivacy(bridge, chatSessionId);
assert.equal(privacy?.enabled, false);
const model = new MockLanguageModelV4({
doGenerate: {
content: [{ type: 'text', text: 'ok' }],
finishReason: { unified: 'stop', raw: undefined },
usage,
warnings: [],
},
});
const protectedModel = protectModel(model, privacy);
const params: LanguageModelV4CallOptions = {
prompt: [{ role: 'user', content: [{ type: 'text', text: ADDRESS }] }],
};
await protectedModel.doGenerate(params);
assert.match(JSON.stringify(model.doGenerateCalls[0].prompt), /203\.0\.113\.19/);
service.configure({ enabled: true });
await assert.rejects(async () => protectedModel.doGenerate(params), /privacy protection is unavailable/);
assert.equal(model.doGenerateCalls.length, 1);
});
for (const method of ['doGenerate', 'doStream'] as const) {
test(`enabling privacy after prompt transformation blocks the ${method} provider handoff`, async (t) => {
const { bridge, service } = await fixture(t);
service.configure({ enabled: false });
const chatSessionId = `${t.name}-off`;
t.after(() => setReversibleModelPrivacy(chatSessionId, false));
const privacy = await initializeModelPrivacy(bridge, chatSessionId);
assert.ok(privacy);
assert.equal(privacy.enabled, false);
const model = new MockLanguageModelV4({
doGenerate: {
content: [{ type: 'text', text: 'ok' }],
finishReason: { unified: 'stop', raw: undefined },
usage,
warnings: [],
},
doStream: stream([]),
});
const middleware = createModelPrivacyMiddleware(privacy);
const protectedModel = wrapLanguageModel({
model,
middleware: {
...middleware,
async transformParams(options) {
const params = await middleware.transformParams!(options);
// Model the SDK await gap after the disabled prompt was validated.
service.configure({ enabled: true });
return params;
},
},
});
await assert.rejects(async () => protectedModel[method]({
prompt: [{ role: 'user', content: [{ type: 'text', text: ADDRESS }] }],
}), /privacy protection is unavailable/);
assert.equal(model.doGenerateCalls.length, 0);
assert.equal(model.doStreamCalls.length, 0);
});
}
+361
View File
@@ -0,0 +1,361 @@
import { wrapLanguageModel, type LanguageModelMiddleware } from 'ai';
import type {
LanguageModelV4,
LanguageModelV4CallOptions,
LanguageModelV4Content,
LanguageModelV4StreamPart,
LanguageModelV4ToolCall,
} from '@ai-sdk/provider';
import { setReversibleModelPrivacy } from './modelSecretRedaction';
import type { OpenAIChatAssistantFields } from '../providerContinuation';
export interface ModelPrivacyBridge {
aiPrivacyStatus?: () => Promise<{ ok?: boolean; enabled?: boolean; error?: string }>;
aiPrivacyTransform?: (input: {
chatSessionId: string;
operation: 'redact' | 'restore';
value: unknown;
}) => Promise<{ ok: boolean; value?: unknown; error?: string }>;
}
export interface ModelPrivacy {
enabled: boolean;
transform<T>(operation: 'redact' | 'restore', value: T): Promise<T>;
}
export interface ModelPrivacyContext {
getOpenAIChatAssistantFields?: () => Array<OpenAIChatAssistantFields | undefined>;
}
const PRIVACY_ERROR = 'AI privacy protection is unavailable. No unprotected request was sent.';
const BINARY_ERROR = 'AI privacy protection cannot inspect this attachment. Use a text attachment instead.';
const SIGNED_REASONING_ERROR = 'AI privacy protection cannot safely rewrite signed reasoning from an earlier conversation. Start a new chat.';
const TOKEN_PREFIX = '[NC_';
/** Revalidate the pinned policy after approval/queue waits, before tool effects. */
export async function assertModelPrivacySession(
bridge: ModelPrivacyBridge,
chatSessionId?: string,
): Promise<void> {
if (!bridge.aiPrivacyStatus) return;
if (!bridge.aiPrivacyTransform || !chatSessionId) throw new Error(PRIVACY_ERROR);
try {
// Restore cannot create a map. Deleted/retired chats must not be reopened.
const result = await bridge.aiPrivacyTransform({
chatSessionId, operation: 'restore', value: [],
});
if (!result?.ok) throw new Error(PRIVACY_ERROR);
} catch {
throw new Error(PRIVACY_ERROR);
}
}
/** Main owns the reverse map; the renderer retains only the enabled flag. */
export async function initializeModelPrivacy(
bridge: ModelPrivacyBridge,
chatSessionId: string,
): Promise<ModelPrivacy | undefined> {
setReversibleModelPrivacy(chatSessionId, false);
if (!bridge.aiPrivacyStatus) return undefined;
let status: { ok?: boolean; enabled?: boolean };
try {
status = await bridge.aiPrivacyStatus();
} catch {
throw new Error(PRIVACY_ERROR);
}
if (status?.ok === false || typeof status?.enabled !== 'boolean') throw new Error(PRIVACY_ERROR);
if (!bridge.aiPrivacyTransform) throw new Error(PRIVACY_ERROR);
const privacy: ModelPrivacy = {
enabled: status.enabled,
async transform<T>(operation: 'redact' | 'restore', value: T): Promise<T> {
let result;
try {
result = await bridge.aiPrivacyTransform!({ chatSessionId, operation, value });
} catch {
throw new Error(PRIVACY_ERROR);
}
if (!result?.ok) throw new Error(PRIVACY_ERROR);
return result.value as T;
},
};
// Pin even disabled chats. A policy switch must not leave a running turn
// using an unprotected model selected under the previous policy.
await privacy.transform('redact', []);
let pinnedStatus;
try {
pinnedStatus = await bridge.aiPrivacyStatus();
} catch {
throw new Error(PRIVACY_ERROR);
}
if (pinnedStatus?.ok === false || pinnedStatus?.enabled !== privacy.enabled) {
throw new Error(PRIVACY_ERROR);
}
setReversibleModelPrivacy(chatSessionId, privacy.enabled);
return privacy;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function textOnlyAttachment(value: unknown): unknown {
let payload = value;
if (typeof value === 'string') {
try { payload = JSON.parse(value); } catch { return value; }
}
if (!isRecord(payload) || !('base64Data' in payload)) return value;
if (payload.base64Data && typeof payload.text !== 'string') throw new Error(BINARY_ERROR);
const { base64Data: _encoded, ...textPayload } = payload;
return typeof value === 'string' ? JSON.stringify(textPayload) : textPayload;
}
/**
* Select semantic content only. IDs, schemas, provider settings and signed
* continuation metadata must never pass through a generic recursive rewrite.
* A single batch lets the shared detector discover repeated secrets regardless
* of their position in the prompt.
*/
export async function redactModelPrompt(
params: LanguageModelV4CallOptions,
privacy: ModelPrivacy,
assistantFields: Array<OpenAIChatAssistantFields | undefined> = [],
): Promise<LanguageModelV4CallOptions> {
if (!privacy.enabled) {
await privacy.transform('redact', []);
return params;
}
const values: unknown[] = [];
const apply: Array<(value: unknown) => void> = [];
const queue = (value: unknown, assign: (value: unknown) => void) => {
values.push(value);
apply.push(assign);
};
// Plain continuation reasoning is injected later by the provider fetch.
// Discover its labelled secrets in this same batch so bare occurrences in
// the normal prompt cannot escape before that late injection is transformed.
// Do not rewrite stored continuation bytes or touch opaque signed metadata.
for (const entry of assistantFields) {
if (typeof entry?.reasoning_content === 'string') {
queue(entry.reasoning_content, () => {});
}
}
const field = (record: Record<string, unknown>, key: string) => {
if (record[key] !== undefined) queue(record[key], value => { record[key] = value; });
};
const file = (part: Record<string, unknown>) => {
const data = part.data;
if (!isRecord(data) || !/^(?:text\/|application\/(?:json|xml|javascript|x-yaml|yaml|toml|x-sh|csv)(?:;|$))/i.test(String(part.mediaType))) {
throw new Error(BINARY_ERROR);
}
if (data.type === 'text' && typeof data.text === 'string') {
const copy = { ...data };
part.data = copy;
field(copy, 'text');
} else if (data.type === 'data' && (typeof data.data === 'string' || data.data instanceof Uint8Array)) {
let text: string;
try {
const bytes = typeof data.data === 'string'
? Uint8Array.from(atob(data.data), char => char.charCodeAt(0))
: data.data;
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
throw new Error(BINARY_ERROR);
}
queue(text, value => {
if (typeof value !== 'string') throw new Error(PRIVACY_ERROR);
part.data = { type: 'data', data: new TextEncoder().encode(value) };
});
} else {
// URL/reference attachments would be fetched outside this text boundary.
throw new Error(BINARY_ERROR);
}
field(part, 'filename');
};
const output = (value: unknown, toolName: unknown): unknown => {
if (!isRecord(value)) throw new Error(PRIVACY_ERROR);
const copy = { ...value };
if (['text', 'json', 'error-text', 'error-json'].includes(String(copy.type))) {
if (toolName === 'read_attachment') copy.value = textOnlyAttachment(copy.value);
field(copy, 'value');
} else if (copy.type === 'execution-denied') {
field(copy, 'reason');
} else if (copy.type === 'content' && Array.isArray(copy.value)) {
copy.value = copy.value.map(part => content(part));
} else {
throw new Error(PRIVACY_ERROR);
}
return copy;
};
const content = (value: unknown): unknown => {
if (!isRecord(value)) throw new Error(PRIVACY_ERROR);
const copy = { ...value };
switch (copy.type) {
case 'text':
field(copy, 'text');
break;
case 'reasoning':
if (copy.providerOptions) {
// Old signed history may contain private source text. Reject rather
// than either leak it or change bytes that the provider will verify.
queue(copy.text, redacted => {
if (redacted !== copy.text) throw new Error(SIGNED_REASONING_ERROR);
});
} else {
field(copy, 'text');
}
break;
case 'file':
file(copy);
break;
case 'tool-call':
field(copy, 'input');
break;
case 'tool-result':
copy.output = output(copy.output, copy.toolName);
break;
case 'tool-approval-response':
field(copy, 'reason');
break;
default:
// New/custom multimodal parts need an explicit privacy adapter.
throw new Error(BINARY_ERROR);
}
return copy;
};
const prompt = params.prompt.map(message => {
if (message.role === 'system') {
const copy = { ...message };
queue(copy.content, value => {
if (typeof value !== 'string') throw new Error(PRIVACY_ERROR);
copy.content = value;
});
return copy;
}
return { ...message, content: message.content.map(part => content(part)) };
});
const redacted = await privacy.transform('redact', values);
if (!Array.isArray(redacted) || redacted.length !== apply.length) throw new Error(PRIVACY_ERROR);
redacted.forEach((value, index) => apply[index](value));
return { ...params, prompt: prompt as LanguageModelV4CallOptions['prompt'] };
}
async function restoreToolCall(
call: LanguageModelV4ToolCall,
privacy: ModelPrivacy,
): Promise<LanguageModelV4ToolCall> {
let input: unknown;
try {
input = JSON.parse(call.input);
} catch {
// Let the SDK report malformed JSON. It cannot execute this tool call.
return call;
}
const restored = await privacy.transform('restore', input);
return { ...call, input: JSON.stringify(restored) };
}
/** Hold only a possible token suffix, never whole model responses. */
function splitSafeText(text: string): [string, string] {
const start = text.lastIndexOf(TOKEN_PREFIX);
if (start >= 0 && text.indexOf(']', start) < 0) {
if (text.length - start > 256) throw new Error(PRIVACY_ERROR);
return [text.slice(0, start), text.slice(start)];
}
for (let length = TOKEN_PREFIX.length - 1; length > 0; length--) {
if (text.endsWith(TOKEN_PREFIX.slice(0, length))) {
return [text.slice(0, -length), text.slice(-length)];
}
}
return [text, ''];
}
export function createModelPrivacyMiddleware(
privacy: ModelPrivacy,
context: ModelPrivacyContext = {},
): LanguageModelMiddleware {
const restoreText = async (text: string) => {
const value = await privacy.transform('restore', text);
if (typeof value !== 'string') throw new Error(PRIVACY_ERROR);
return value;
};
return {
specificationVersion: 'v4',
transformParams: ({ params }) => redactModelPrompt(
params, privacy, context.getOpenAIChatAssistantFields?.(),
),
wrapGenerate: async ({ doGenerate }) => {
// The SDK awaits transformed params before entering this provider handoff.
await privacy.transform('restore', []);
if (!privacy.enabled) return doGenerate();
const result = await doGenerate();
const content: LanguageModelV4Content[] = [];
for (const part of result.content) {
content.push(part.type === 'tool-call'
? await restoreToolCall(part, privacy)
: part.type === 'text' ? { ...part, text: await restoreText(part.text) } : part);
}
return { ...result, content };
},
wrapStream: async ({ doStream }) => {
await privacy.transform('restore', []);
if (!privacy.enabled) return doStream();
const result = await doStream();
const pending = new Map<string, { text: string; chunk: Extract<LanguageModelV4StreamPart, { type: 'text-delta' }> }>();
const flush = async (
controller: TransformStreamDefaultController<LanguageModelV4StreamPart>,
id?: string,
) => {
for (const [key, entry] of [...pending]) {
if (id !== undefined && key !== id) continue;
pending.delete(key);
if (entry.text) controller.enqueue({ ...entry.chunk, delta: await restoreText(entry.text) });
}
};
return {
...result,
stream: result.stream.pipeThrough(new TransformStream<LanguageModelV4StreamPart, LanguageModelV4StreamPart>({
async transform(chunk, controller) {
if (chunk.type === 'text-delta') {
const [safe, suffix] = splitSafeText((pending.get(chunk.id)?.text ?? '') + chunk.delta);
pending.set(chunk.id, { text: suffix, chunk });
if (safe) controller.enqueue({ ...chunk, delta: await restoreText(safe) });
return;
}
if (chunk.type === 'text-end') await flush(controller, chunk.id);
if (chunk.type === 'finish' || chunk.type === 'error') await flush(controller);
controller.enqueue(chunk.type === 'tool-call' ? await restoreToolCall(chunk, privacy) : chunk);
},
flush,
})),
};
},
};
}
export function protectModel(
model: LanguageModelV4,
privacy?: ModelPrivacy,
context?: ModelPrivacyContext,
): LanguageModelV4 {
return privacy ? wrapLanguageModel({ model, middleware: createModelPrivacyMiddleware(privacy, context) }) : model;
}
/** OpenAI-compatible reasoning_content is injected after SDK prompt conversion. */
export async function redactOpenAIChatAssistantFields(
fields: Array<OpenAIChatAssistantFields | undefined>,
privacy: ModelPrivacy,
): Promise<Array<OpenAIChatAssistantFields | undefined>> {
if (!privacy.enabled) {
await privacy.transform('redact', []);
return fields;
}
const text = fields.map(entry => typeof entry?.reasoning_content === 'string'
? entry.reasoning_content : null);
const redacted = await privacy.transform('redact', text);
if (!Array.isArray(redacted) || redacted.length !== fields.length) throw new Error(PRIVACY_ERROR);
return fields.map((entry, index) => {
if (text[index] === null) return entry;
if (typeof redacted[index] !== 'string') throw new Error(PRIVACY_ERROR);
return { ...entry, reasoning_content: redacted[index] };
});
}
@@ -1,4 +1,15 @@
const REDACTED = '[REDACTED]';
const reversibleSessions = new Set<string>();
/** Only bypass legacy masking when a fail-closed model boundary owns this chat. */
export function setReversibleModelPrivacy(chatSessionId: string, enabled: boolean): void {
if (enabled) reversibleSessions.add(chatSessionId);
else reversibleSessions.delete(chatSessionId);
}
export function hasReversibleModelPrivacy(chatSessionId?: string): boolean {
return !!chatSessionId && reversibleSessions.has(chatSessionId);
}
const PRIVATE_KEY_PATTERN = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/g;
const BEARER_PATTERN = /\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
@@ -15,7 +26,8 @@ function isSecretKey(key: string): boolean {
}
/** Redacts likely credentials only on data that is about to become model-visible. */
export function redactSecretsForModel(value: string): string {
export function redactSecretsForModel(value: string, chatSessionId?: string): string {
if (hasReversibleModelPrivacy(chatSessionId)) return value;
if (!value) return value;
return value
.replace(PRIVATE_KEY_PATTERN, REDACTED)
@@ -29,15 +41,16 @@ export function redactSecretsForModel(value: string): string {
}
/** Recursively redacts model-visible event arguments without mutating the source value. */
export function redactSecretsInValueForModel<T>(value: T): T {
if (typeof value === 'string') return redactSecretsForModel(value) as T;
if (Array.isArray(value)) return value.map(redactSecretsInValueForModel) as T;
export function redactSecretsInValueForModel<T>(value: T, chatSessionId?: string): T {
if (hasReversibleModelPrivacy(chatSessionId)) return value;
if (typeof value === 'string') return redactSecretsForModel(value, chatSessionId) as T;
if (Array.isArray(value)) return value.map(entry => redactSecretsInValueForModel(entry, chatSessionId)) as T;
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.map(([key, entry]) => [
key,
isSecretKey(key) ? REDACTED : redactSecretsInValueForModel(entry),
isSecretKey(key) ? REDACTED : redactSecretsInValueForModel(entry, chatSessionId),
]),
) as T;
}
+2 -2
View File
@@ -262,14 +262,14 @@ export class SessionStateStore {
if (hosts.length) {
const hostSummary = hosts
.slice(-5)
.map(([id, host]) => `${id}${host.lastCommand ? ` (last: ${redactSecretsForModel(host.lastCommand)})` : ''}`)
.map(([id, host]) => `${id}${host.lastCommand ? ` (last: ${redactSecretsForModel(host.lastCommand, chatSessionId)})` : ''}`)
.join(', ');
lines.push(`Active hosts: ${hostSummary}`);
}
const jobs = Object.entries(state.activeJobs);
if (jobs.length) {
const jobSummary = jobs.slice(-5).map(([jobId, job]) => (
`${jobId} (status=${job.status}, offset=${job.nextOffset}${job.sessionId ? `, session=${job.sessionId}` : ''}${job.command ? `, command=${redactSecretsForModel(job.command)}` : ''})`
`${jobId} (status=${job.status}, offset=${job.nextOffset}${job.sessionId ? `, session=${job.sessionId}` : ''}${job.command ? `, command=${redactSecretsForModel(job.command, chatSessionId)}` : ''})`
)).join('; ');
lines.push(`Remembered terminal jobs (status is unverified after compaction): ${jobSummary}. Poll the existing job from its saved offset to verify current status; do not restart its command.`);
}
@@ -138,12 +138,12 @@ function replaceToolResultText(part: Record<string, unknown>, text: string): Rec
return { ...part, output: { type: 'text', value: text } };
}
function redactToolCallInputs(message: ModelMessage): ModelMessage {
function redactToolCallInputs(message: ModelMessage, chatSessionId?: string): ModelMessage {
if (message.role !== 'assistant' || !Array.isArray(message.content)) return message;
let changed = false;
const content = (message.content as unknown[]).map(part => {
if (!isRecord(part) || part.type !== 'tool-call' || !('input' in part)) return part;
const redacted = redactSecretsInValueForModel(part.input);
const redacted = redactSecretsInValueForModel(part.input, chatSessionId);
if (JSON.stringify(redacted) === JSON.stringify(part.input)) return part;
changed = true;
return { ...part, input: redacted };
@@ -179,6 +179,7 @@ function compressMessageToolResults(
}
export interface PruneStaleToolContextOptions {
chatSessionId?: string;
/** Supersede stale reads and omit older terminal output only under context budget pressure. */
underBudgetPressure?: boolean;
}
@@ -243,7 +244,7 @@ export function pruneStaleToolContext(
if (keepTerminalIndices.has(entry.index)) continue;
terminalOmitByIndex.set(
entry.index,
`${EARLIER_TERMINAL_PREFIX} command=${redactSecretsForModel(entry.command ?? 'unknown')}]`,
`${EARLIER_TERMINAL_PREFIX} command=${redactSecretsForModel(entry.command ?? 'unknown', options.chatSessionId)}]`,
);
}
}
@@ -251,7 +252,7 @@ export function pruneStaleToolContext(
let didAdjust = false;
const next = messages.map((message, index) => {
const redactedMessage = redactToolCallInputs(message);
const redactedMessage = redactToolCallInputs(message, options.chatSessionId);
const updated = compressMessageToolResults(redactedMessage, (toolName, args, text, isError) => {
if (isError) return null;
const readKey = readFingerprint(toolName, args);
@@ -33,17 +33,17 @@ export function fitTerminalExecuteResultForModel(
options?: FitTerminalExecuteResultOptions,
): TerminalExecuteResult {
const stdout = truncateTextWithHeadAndTail(
redactSecretsForModel(compressVerboseText(result.stdout)),
redactSecretsForModel(compressVerboseText(result.stdout), options?.chatSessionId),
MAX_LIVE_TERMINAL_STDOUT_CHARS,
);
const stderr = truncateTextWithHeadAndTail(
redactSecretsForModel(compressVerboseText(result.stderr)),
redactSecretsForModel(compressVerboseText(result.stderr), options?.chatSessionId),
MAX_LIVE_TERMINAL_STDERR_CHARS,
);
const fitted: TerminalExecuteResult = {
...result,
command: result.command ? redactSecretsForModel(result.command) : result.command,
command: result.command ? redactSecretsForModel(result.command, options?.chatSessionId) : result.command,
stdout,
stderr,
};
@@ -71,7 +71,7 @@ export function fitTerminalExecuteResultForModel(
const handle: TerminalOutputHandle = {
kind: 'terminal-output',
sessionId: result.sessionId ?? 'unknown',
command: result.command ? redactSecretsForModel(result.command) : result.command,
command: result.command ? redactSecretsForModel(result.command, options?.chatSessionId) : result.command,
totalStdoutChars: result.stdout.length,
totalStderrChars: result.stderr.length,
handleId,
@@ -81,7 +81,7 @@ function fitValue(value: unknown, ctx: FitValueContext): unknown {
}
function fitString(value: string, ctx: FitValueContext): string {
const safeValue = redactSecretsForModel(value);
const safeValue = redactSecretsForModel(value, ctx.chatSessionId);
const normalizedValue = ctx.normalizeStrings ? compressVerboseText(safeValue) : safeValue;
if (safeValue.length <= ctx.maxStringChars && normalizedValue.length <= ctx.maxStringChars) {
return normalizedValue;
@@ -342,7 +342,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: redactSecretsInValueForModel(tc.arguments ?? {}),
input: redactSecretsInValueForModel(tc.arguments ?? {}, chatSessionId),
...(providerOptions ? { providerOptions } : {}),
});
}
@@ -397,7 +397,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
// Context-budget pruning still owns necessary reductions.
value: redactSecretsForModel(buildHistoricalToolResultReplayText(tr, toolCall, {
preserveTerminalOutput: true,
})),
}), chatSessionId),
},
};
}),
@@ -1,4 +1,5 @@
import { streamText, isStepCount, type ModelMessage } from 'ai';
import type { LanguageModelV4 } from '@ai-sdk/provider';
import { classifyError } from '../../errorClassifier';
import { isRequestTooLargeError } from '../../errorClassifier';
import { isSdkStreamStateError } from '../../shared/streamStateErrors';
@@ -10,7 +11,7 @@ import { mapCattyStreamChunkToAgentEvents } from '../agentEventAdapter';
import type { AgentEvent } from '../types';
import { resolveProviderStyle, type ProviderAdvancedParams } from '../../types';
import type { CattyReasoningProviderOptions } from '../../cattyReasoning';
import { createModelFromConfig, resolveProviderEndpoint } from '../../sdk/providers';
import { resolveProviderEndpoint } from '../../sdk/providers';
import type { CattyToolsBundle } from '../capabilityTools';
import { buildCattyToolApproval } from '../cattyToolApproval';
import type { CattyRuntimeContext } from '../cattyRuntimeContext';
@@ -42,7 +43,7 @@ import {
} from '../../aiChatStreamingSupport';
import type { ChatMessage, ProviderConfig } from '../../types';
export type CattyModel = ReturnType<typeof createModelFromConfig>;
export type CattyModel = LanguageModelV4;
export interface CattyStreamUiSink {
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
@@ -43,6 +43,7 @@ import { hadToolProgressBeforeRequestTooLarge, processCattyStream } from './catt
import type { CattyTurnInput, TurnDriver, TurnDriverContext } from './types';
import { fitLargeUserInputForModel } from '../largeUserInput';
import { buildPromptContextSnapshot } from '../promptContextSnapshot';
import { initializeModelPrivacy, protectModel, redactOpenAIChatAssistantFields, type ModelPrivacyBridge } from '../modelPrivacy';
export class CattyTurnDriver implements TurnDriver {
readonly backend = 'catty' as const;
@@ -74,6 +75,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
} = input;
const netcattyBridge = (bridge ?? getNetcattyBridge()) as NonNullable<ReturnType<typeof getNetcattyBridge>>;
const privacy = await initializeModelPrivacy(netcattyBridge as ModelPrivacyBridge, sessionId);
const toolOutputTempBridge = netcattyBridge as typeof netcattyBridge & {
getToolOutputPersistenceStatus?: () => Promise<{ durable: boolean; reason?: string }>;
writeToolOutputTemp?: (
@@ -237,17 +239,27 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
let model;
try {
model = createModelFromConfig(
model = protectModel(createModelFromConfig(
{
...context.activeProvider,
defaultModel: activeModelId,
},
{
getOpenAIChatAssistantFields: () => continuationContext.openAIChatAssistantFields,
transformOpenAIChatAssistantFields: privacy
? fields => redactOpenAIChatAssistantFields(fields, privacy)
: undefined,
streamIdleTimeoutMs: responseIdleTimeoutMs,
chatSessionId: sessionId,
},
);
), privacy, {
// Compaction can run before prepareMessagesForStream selects replay
// fields. Include built history plus freshly captured live fields.
getOpenAIChatAssistantFields: () => [
...openAIChatAssistantFieldsByMessage.values(),
...continuationContext.openAIChatAssistantFields,
],
});
} catch (e) {
console.error('[Catty] Model creation failed:', e);
ui.reportStreamError(sessionId, signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
+8 -1
View File
@@ -20,6 +20,9 @@ import {
export interface ProviderRequestContext {
getOpenAIChatAssistantFields?: () => Array<OpenAIChatAssistantFields | undefined>;
transformOpenAIChatAssistantFields?: (
fields: Array<OpenAIChatAssistantFields | undefined>,
) => Promise<Array<OpenAIChatAssistantFields | undefined>>;
streamIdleTimeoutMs?: number;
/** Stable per-conversation id; used e.g. for OpenCode's x-opencode-session header. */
chatSessionId?: string;
@@ -473,10 +476,14 @@ export function createBridgeFetchForSDK(
const headers = extractHeaders(resolvedInit?.headers);
const body =
resolvedInit?.body != null ? String(resolvedInit.body) : undefined;
const assistantFields = requestContext?.getOpenAIChatAssistantFields?.() ?? [];
const safeAssistantFields = requestContext?.transformOpenAIChatAssistantFields
? await requestContext.transformOpenAIChatAssistantFields(assistantFields)
: assistantFields;
const requestBody = body != null
? repairOpenAIChatToolResultPairsInBody(applyOpenAIChatContinuationToBody(
body,
requestContext?.getOpenAIChatAssistantFields?.() ?? [],
safeAssistantFields,
))
: undefined;
+4
View File
@@ -235,6 +235,10 @@ export const STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES = 'netcatty_ai_session_
export const STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS = 'netcatty_ai_external_mcp_silent_sessions_v1';
/** External MCP confirm-mode approval alert: off, unfocused, or always. Default unfocused. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT = 'netcatty_ai_external_mcp_approval_alert_v1';
/** Shared AI privacy settings for Netcatty-managed context and tools. Device-local and off by default. */
export const STORAGE_KEY_AI_PRIVACY = 'netcatty_ai_privacy_v1';
/** Legacy External MCP privacy key, read only when no shared AI privacy preference exists. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION = 'netcatty_ai_external_mcp_redaction_v1';
/** External MCP Streamable HTTP listener. Off unless the user enables it. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED = 'netcatty_ai_external_mcp_http_enabled_v1';
/** Fixed Streamable HTTP port so client configs stay stable across restarts. */
+1 -1
View File
@@ -55,7 +55,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"pretest": "npm run build:plugin-runtime-deps && npm run check:codebuddy-sdk-contract",
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts domain/*/*.test.cjs infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/shared/*Privacy*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts domain/*/*.test.cjs infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps",
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs",
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more