mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-09 01:20:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f2d7a70c6 | ||
|
|
4a1e547466 | ||
|
|
027b54f651 | ||
|
|
a711cb0719 | ||
|
|
4caa53d387 | ||
|
|
d5c3b31ffd |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = []; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user