feat(plugins): default declared capabilities to allow with scoped policy controls

This commit is contained in:
陈奇 committed 2026-10-27 02:28:08 +00:00
1 parent 677aac33b1
commit 643e71b17b
47 files changed
+1138 -200

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginPermissionHost } from './components/plugins/PluginPermissionHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
@@ -88,6 +89,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<PluginPermissionHost />
<PluginHostInteractionHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
+8 -4
View File
@@ -1298,19 +1298,23 @@ Highlight the focused split pane:
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Matches the current activation",
"settings.plugins.permissions.inactive": "Inactive for the current activation",
"settings.plugins.permissions.title": "Saved grants",
"settings.plugins.permissions.note": "Declared capabilities default to Allow. Ask requests a scoped decision; Deny blocks access immediately. Target selection and draft editing still use the host UI.",
"settings.plugins.permissions.allow": "Allow",
"settings.plugins.permissions.ask": "Ask",
"settings.plugins.permissions.deny": "Deny",
"settings.plugins.permissions.title": "Permissions",
"settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...",
"settings.plugins.permissions.loading": "Loading permissions...",
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Your Allow / Ask / Deny settings are retained.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
"settings.plugins.manager.permissionNote": "Declared supported capabilities default to Allow. Change each capability to Ask or Deny in the permission controls. Host validation and resource scope always apply.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
+8 -4
View File
@@ -1213,19 +1213,23 @@ Vaults 标签保持扁平,不使用这块背景。
"plugins.interaction.closeDraft": "关闭草稿",
"settings.plugins.permissions.current": "匹配当前运行实例",
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
"settings.plugins.permissions.title": "已保存的授权",
"settings.plugins.permissions.note": "已声明且受支持的权限默认允许。询问时显示一次范围明确的确认;拒绝会立即阻止访问。选择目标和编辑草稿仍使用宿主界面。",
"settings.plugins.permissions.allow": "允许",
"settings.plugins.permissions.ask": "询问",
"settings.plugins.permissions.deny": "拒绝",
"settings.plugins.permissions.title": "权限",
"settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在读取授权...",
"settings.plugins.permissions.loading": "正在加载权限...",
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。允许 / 询问 / 拒绝设置会保留。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.permissionNote": "已声明且受支持的权限默认允许。可以在权限设置中逐项改为询问或拒绝;宿主验证和资源范围限制始终生效。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
+8 -4
View File
@@ -1216,19 +1216,23 @@ Vaults 分頁維持扁平,不使用這塊背景。
"plugins.interaction.closeDraft": "關閉草稿",
"settings.plugins.permissions.current": "符合目前執行個體",
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
"settings.plugins.permissions.title": "已儲存的授權",
"settings.plugins.permissions.note": "已宣告且受支援的權限預設允許。詢問時顯示一次範圍明確的確認;拒絕會立即阻止存取。選擇目標和編輯草稿仍使用宿主介面。",
"settings.plugins.permissions.allow": "允許",
"settings.plugins.permissions.ask": "詢問",
"settings.plugins.permissions.deny": "拒絕",
"settings.plugins.permissions.title": "權限",
"settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在讀取授權...",
"settings.plugins.permissions.loading": "正在載入權限...",
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。允許 / 詢問 / 拒絕設定會保留。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.permissionNote": "已宣告且受支援的權限預設允許。可以在權限設定中逐項改為詢問或拒絕;宿主驗證和資源範圍限制始終生效。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
+8 -3
View File
@@ -112,7 +112,7 @@ export function usePluginHostActions() {
const entry = current.current;
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
entry.prompt = prompt;
setReview({ ...entry });
if (!(prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow')) setReview({ ...entry });
void execute(entry);
return;
}
@@ -126,7 +126,12 @@ export function usePluginHostActions() {
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
claimed: false,
};
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
current.current = entry; setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
if (prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow') {
setReview(null);
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken,
action: 'submit', scriptsRun: entry.scriptsRun }).catch(() => void fail(entry));
} else setReview(entry);
} catch {
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
}
@@ -137,7 +142,7 @@ export function usePluginHostActions() {
unsubscribe();
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
};
}, [execute, getVault]);
}, [execute, fail, getVault]);
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
const entry = current.current;
+10 -1
View File
@@ -20,7 +20,16 @@ export function usePluginHostInteractions() {
current.current = null; setPrompt(null);
} else {
if (event.request.kind.startsWith('vault.')) return;
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
current.current = event as UiPrompt;
if (event.request.kind === 'terminal.selection.read' && event.phase === 'request') {
setPrompt(null);
try {
const selection = readPluginTerminalSelection(event.terminalSessionId ?? '', event.terminalRendererId ?? '');
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'preview', value: selection }).catch(() => {
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {});
});
} catch { void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {}); }
} else setPrompt(event as UiPrompt);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : event.request.kind === 'terminal.input.propose' ? event.request.text : '');
}
pending.current = false; setBusy(false); setError(false);
@@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginPermissionPrompt() {
const [prompts, setPrompts] = useState<NetcattyPluginPermissionPrompt[]>([]);
const [busy, setBusy] = useState(false);
const active = useRef<NetcattyPluginPermissionPrompt | null>(null);
const prompt = prompts[0];
active.current = prompt ?? null;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginPermission || !bridge.setPluginPermissionReady) return;
const unsubscribe = bridge.onPluginPermission((event) => {
setPrompts((previous) => 'closed' in event ? previous.filter((item) => item.requestId !== event.requestId)
: [...previous.filter((item) => item.requestId !== event.requestId), event]);
setBusy(false);
});
void bridge.setPluginPermissionReady(true).catch(() => {});
return () => { unsubscribe(); void bridge.setPluginPermissionReady?.(false).catch(() => {}); };
}, []);
const respond = async (decision: 'allow' | 'deny' | 'cancel') => {
if (!prompt || busy || active.current !== prompt) return;
setBusy(true);
try {
const accepted = await netcattyBridge.require().respondPluginPermission?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
if (!accepted) setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId));
} catch { setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId)); }
finally { setBusy(false); }
};
return { prompt, busy, respond };
}
@@ -109,6 +109,7 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
const archive = path.join(directory, 'fixture.ncpkg');
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
service.database.setPermissionMode('com.netcatty.host-actions', 'ui.interact', 'ask');
service.interactionService.setReady(sender, true);
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
@@ -192,8 +193,8 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
await waitFor(() => preparing);
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
await act(async () => { service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'deny'); release(); await settle(); });
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined; service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'allow');
});
await t.test('source replacement during review cannot use the old action authority', async () => {
@@ -272,13 +273,44 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
}
});
await t.test('Allow connects through the selected target without a confirmation dialog; Deny blocks every Host action', async () => {
const id = 'com.netcatty.host-actions';
const plugin = service.database.getActivePlugin(id);
const set = (permission: string, mode: string) => service.permissionEngine.setMode(plugin, permission, mode);
set('ui.interact', 'allow');
const allowed = await begin('connect'); await chooseGroup();
await act(async () => { await allowed.promise; await settle(); });
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(connections.length, 2);
for (const [permission, command] of [['vault.metadata', 'select'], ['vault.write', 'create']] as const) {
set(permission, 'deny'); requestClock += 60_001;
await assert.rejects(service.contributionService.executeCommand(`${id}.${command}`), /disabled/);
assert.equal(document.querySelector('[role="dialog"]'), null);
set(permission, 'allow');
}
set('ui.interact', 'deny');
const denied = await begin('connect'); await chooseGroup();
await assert.rejects(denied.promise, /disabled/);
assert.equal(connections.length, 2); assert.equal(saves, 1);
set('ui.interact', 'ask'); set('vault.write', 'ask');
const draft = await begin('create');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.ok(button('Save Host')); await click(button('Cancel'));
assert.deepEqual(await draft.promise, { cancelled: true });
set('vault.write', 'allow'); set('vault.metadata', 'ask');
const selection = await begin('select'); await chooseGroup();
assert.ok('hosts' in await selection.promise);
assert.equal(document.querySelector('[role="dialog"]'), null);
set('vault.metadata', 'allow');
});
await t.test('runtime removal rejects old signed replies', async () => {
const run = await begin('create');
const old = prompts.at(-1)!;
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
await assert.rejects(run.promise);
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
assert.equal(saves, 1); assert.equal(connections.length, 1);
assert.equal(saves, 1); assert.equal(connections.length, 2);
});
} finally {
await act(async () => { root.unmount(); await settle(); });
+6 -13
View File
@@ -3,7 +3,7 @@ import test from 'node:test';
import { EventEmitter } from 'node:events';
import { JSDOM } from 'jsdom';
test('host dialog drives choice/input/copy and the two-phase clipboard share using real service action tokens', async () => {
test('host dialog drives choice/input/copy and a single scoped clipboard share using real service action tokens', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
@@ -80,9 +80,6 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
assert.deepEqual(writes, ['synthetic output']);
const share = await begin({ kind: 'clipboard.read', title: 'Share clipboard' });
assert.equal(reads, 0);
assert.equal(document.querySelector('textarea'), null);
await click(button('Preview clipboard'));
assert.equal(reads, 1);
assert.equal(document.querySelector('textarea')?.value, 'synthetic private text');
await click(button('Share with plugin'));
@@ -105,17 +102,15 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Synthetic selected terminal/);
await click(button('Cancel'));
assert.deepEqual(await noShare.promise, { cancelled: true });
assert.equal(selectionReads, 0);
const selected = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
assert.equal(selectionReads, 0);
await click(button('Preview terminal selection'));
assert.equal(selectionReads, 1);
const selected = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
assert.equal(selectionReads, 2);
assert.equal(document.querySelector('textarea')?.value, selectionText);
selectionText = 'changed after preview';
await click(button('Share with plugin'));
const result = await selected.promise as { cancelled: false; value: { text: string; handle: string } };
assert.equal(result.value.text, 'synthetic terminal selection\nsecond line');
assert.equal(selectionReads, 1);
assert.equal(selectionReads, 2);
const draft = await begin({ kind: 'terminal.input.propose', handle: result.value.handle, title: 'Synthetic editable review', text: 'first\nsecond\r\u0003\u001b[31m\u0000' });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /sends no terminal input or Enter/);
const textarea = document.querySelector('textarea')!;
@@ -135,11 +130,9 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
assert.deepEqual(writes, ['synthetic output']);
selectionState.visible = false;
const hidden = await begin({ kind: 'terminal.selection.read', title: 'Hidden terminal' });
await click(button('Preview terminal selection'));
assert.ok(document.querySelector('[role="alert"]'));
assert.equal(selectionReads, 1);
await click(button('Cancel'));
assert.deepEqual(await hidden.promise, { cancelled: true });
assert.equal(selectionReads, 2);
assert.equal(document.querySelector('[role="dialog"]'), null);
} finally { cleanupSelection(); terminalElement.remove(); }
const cancelled = await begin({ kind: 'clipboard.write', title: 'Cancelled copy', text: 'must not copy' });
await click(button('Cancel'));
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
test('registered Ask uses one in-app dialog; policy controls apply Allow and Deny with no hidden prompt', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
const listeners = new Set<(event: unknown) => void>();
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: (_channel: string, event: unknown) => { for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isFocused: () => true, isVisible: () => true });
const host = new PluginPermissionDecisionHost({ getWindow: () => owner, isLocked: () => false });
let mode = 'allow', effects = 0;
const plugin = { id: 'com.example.permission-ui', activeVersion: '1.0.0', manifest: { id: 'com.example.permission-ui', publisher: 'example', permissions: { optional: [{ permission: 'network', resources: ['https://example.com'] }] } } };
const engine = new PluginPermissionEngine({ database: { getPermissionMode: () => mode, privateOwner: () => 'synthetic-source-owner',
setPermissionMode: (_id: string, _permission: string, value: string) => { mode = value; }, recordSecurityAudit() {}, deleteAllPermissionGrants() {}, listPermissionGrants: () => [] },
requestDecision: (...args: unknown[]) => host.request(...args) });
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware());
registry.registerRequest('network.fixture', async () => { effects++; return 'synthetic result'; }, { authorization: { permission: 'network', resources: ['https://example.com'] } });
const route = registry.createRoutes({ pluginId: plugin.id, pluginVersion: '1.0.0', runtimeId: 'runtime-ui', runtimeKind: 'browser', manifest: plugin.manifest }).requestHandlers['network.fixture'];
Object.assign(dom.window, { netcatty: {
onPluginPermission(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
setPluginPermissionReady: async (ready: boolean) => host.setReady(sender, ready),
respondPluginPermission: async (response: unknown) => host.respond(sender, response),
getPluginPermissions: async () => ({ grants: [], audit: [], policies: engine.describePolicies(plugin), policyRevision: engine.getPolicyRevision(plugin) }),
setPluginPermissionMode: async (_id: string, permission: string, value: string, revision: string) => {
assert.equal(revision, engine.getPolicyRevision(plugin)); engine.setMode(plugin, permission, value); return true;
},
} });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === text)!;
const choose = async (text: string) => {
await act(async () => { document.querySelector('[role="combobox"]')!.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await settle(); });
const option = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find((element) => element.textContent === text)!;
assert.ok(option);
await act(async () => { option.click(); await settle(); });
};
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId={plugin.id} onChanged={async () => {}} /><PluginPermissionHost /></I18nProvider>));
await route({}, {}); assert.equal(effects, 1); assert.equal(document.querySelector('[role="dialog"]'), null);
await choose('Ask'); assert.equal(mode, 'ask');
let operation!: Promise<string>;
await act(async () => { operation = route({}, {}); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.match(document.querySelector('[role="dialog"]')!.textContent!, /https:\/\/example.com/);
assert.equal(effects, 1);
await act(async () => { button('Allow this operation').click(); await operation; await settle(); });
assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
await choose('Deny'); assert.equal(mode, 'deny');
await assert.rejects(route({}, {}), /disabled/); assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
await choose('Allow'); assert.equal(mode, 'allow');
await route({}, {}); assert.equal(effects, 3); assert.equal(document.querySelector('[role="dialog"]'), null);
} finally {
await act(async () => root.unmount()); host.shutdown(); engine.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,33 @@
import { usePluginPermissionPrompt } from '../../application/state/usePluginPermissionPrompt';
import { useI18n } from '../../application/i18n/I18nProvider';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
/** Rendered only by the trusted app, never inside a plugin view. */
export function PluginPermissionHost() {
const { resolvedLocale } = useI18n();
const zh = resolvedLocale.startsWith('zh');
const { prompt, busy, respond } = usePluginPermissionPrompt();
if (!prompt) return null;
const description = describePluginPermission(prompt.permission, resolvedLocale);
return <Dialog open onOpenChange={(open) => { if (!open) void respond('cancel'); }}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{description.label}</DialogTitle>
<DialogDescription className="break-words">{prompt.pluginName ?? prompt.pluginId} · {prompt.pluginVersion}</DialogDescription>
</DialogHeader>
<p className="text-sm">{description.description}</p>
{(prompt.relatedPermissions ?? []).map((item, index) => <div key={index} className="space-y-1 text-sm">
<p>{describePluginPermission(item.permission, resolvedLocale).description}</p>
<p className="text-xs text-muted-foreground break-all">{item.resources.join(', ')}</p>
</div>)}
<p className="text-xs text-muted-foreground break-all">{prompt.pluginId}</p>
<ul className="max-h-44 overflow-auto space-y-1 text-xs text-muted-foreground">{prompt.resources.map((resource) => <li key={resource} className="break-all">{resource}</li>)}</ul>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => void respond('deny')}>{zh ? '拒绝' : 'Deny'}</Button>
<Button disabled={busy} onClick={() => void respond('allow')}>{prompt.sessionId && prompt.allowedScopes && !prompt.allowedScopes.includes('once') ? zh ? '允许此会话' : 'Allow for this session' : zh ? '允许此次操作' : 'Allow this operation'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -3,6 +3,8 @@ import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge'
import { useI18n } from '../../../application/i18n/I18nProvider';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { Button } from '../../ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
import { SettingRow } from '../settings-ui';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
@@ -36,6 +38,17 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
void refresh();
return () => { requestGeneration.current++; };
}, [refresh]);
const changeMode = async (permission: string, mode: PluginPermissionMode) => {
if (pending.current) return;
pending.current = true; setBusy(true); setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.setPluginPermissionMode || !snapshot?.policyRevision) throw new Error(t('settings.plugins.permissions.unavailable'));
await bridge.setPluginPermissionMode(pluginId, permission, mode, snapshot.policyRevision);
await onChanged();
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { await refresh(true); pending.current = false; setBusy(false); }
};
const revoke = async () => {
if (pending.current) return;
pending.current = true;
@@ -61,9 +74,18 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.note')}</p>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
{!snapshot && !error && <p role="status">{t('settings.plugins.permissions.loading')}</p>}
{snapshot && <>
{(snapshot.policies ?? []).map((policy) => <SettingRow key={policy.permission}
label={describePluginPermission(policy.permission, resolvedLocale).label}
description={<span className="whitespace-pre-wrap break-all">{describePluginPermission(policy.permission, resolvedLocale).description}{' · '}{policy.resources.join(', ')}</span>}>
<Select value={policy.mode} disabled={busy} onValueChange={(mode) => void changeMode(policy.permission, mode as PluginPermissionMode)}>
<SelectTrigger className="w-28" aria-label={describePluginPermission(policy.permission, resolvedLocale).label}><SelectValue /></SelectTrigger>
<SelectContent>{(['allow', 'ask', 'deny'] as const).map((mode) => <SelectItem key={mode} value={mode}>{t(`settings.plugins.permissions.${mode}`)}</SelectItem>)}</SelectContent>
</Select>
</SettingRow>)}
{snapshot.grants.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.empty')}</p>}
{snapshot.grants.map((grant, index) => <div key={index} className="rounded-md bg-muted/40 p-3 text-xs">
<p className="font-medium">{describePluginPermission(grant.permission, resolvedLocale).label}</p>
@@ -76,7 +98,7 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
<summary className="cursor-pointer">{t('settings.plugins.permissions.audit')}</summary>
<ol className="mt-2 space-y-1">{snapshot.audit.map((entry, index) => <li key={index} className="break-words">
<time dateTime={new Date(entry.createdAt).toISOString()}>{new Date(entry.createdAt).toLocaleString(resolvedLocale)}</time>{' · '}
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}{entry.mode ? ` · ${t(`settings.plugins.permissions.${entry.mode}`)}` : ''}{entry.action ? ` · ${entry.action}` : ''}
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

@@ -0,0 +1,131 @@
# Plugin Allow / Ask / Deny handoff
This change is stacked on frozen PR #3997 at
`677aac33b1eec9dbd4a367417a49c964cdea63e7`. Its source checkout and native QA
checkout were not changed. No merge, release, live database connection, native
activation or real user configuration change was performed here.
## Behavior and storage
- Declared supported capabilities default to Allow, with no installation
checklist or required-permission preflight dialogs.
- Settings uses existing `SettingRow` and Select components. It shows each
declaration's resource scope and effective Allow / Ask / Deny mode.
- Policies use `plugin_host_preferences`, keyed by host source owner and
capability. They survive restart and updates within the same source, including
reintroduced capabilities. New capabilities default to Allow. Existing grants
still use the original package/declaration principal; they were not widened.
- Explicit source reset clears that source's policies and private plugin data.
Another source receives independent defaults. Unrelated Vault, host, settings
and user data are preserved. No plugin-policy migration was added.
- Policy edits clear grants, cancel pending/in-flight work and invalidate
handles. Settings edits require the exact host-issued source/version/policy
revision, so stale controls cannot change a replacement source or override a
newer Deny.
- Ask decisions use the existing in-app Dialog/Button components, reached only
through trusted main-window preload IPC. A one-use response token binds the
request; close, cancel, lock, blur/hide, reload, renderer crash and shutdown
invalidate it. Plugin DOM clicks confer no host gesture authority.
- Host selection, model/context selection and draft editing remain workflows.
Ask is integrated into their existing host UI where applicable. Allow removes
permission-only sharing and connection confirmations while retaining exact
target validation and action tokens. Terminal drafts never send PTY input.
- AI start combines model permission and exact selected-content scopes into one
Ask decision. Its receipt cannot cross an RPC operation. Broker-bound stream
reads/stop/close do not ask per chunk. Same-origin network redirects reuse the
current operation decision; different origins still require declared bounds
and their own authorization.
- Policy edits, decisions, use/revoke and operation outcomes remain auditable.
Audit records omit note/terminal bodies, model payloads, credentials and tokens.
Optional local log persistence remains user-controlled.
See [the security boundary](security-and-permissions.md) for the storage and
update rules. The separate AI cold-start provider-sync fix is not included;
`configuredModel.ts`, `trustedHostRuntime.ts` and `usePluginAIContextHost.ts`
were left untouched for parent integration.
## Local evidence
These are local code/test results, not native acceptance:
| Check | Result |
| --- | --- |
| Registered runtime aggregate, concurrency 2 | 693 tests: 691 passed, one failed, one native skip; no cancellations. Sole failure imports the missing Electron binary on real worker reconnect. |
| Engine, policy matrix, trusted prompt and policy IPC group | 79 passed; subsequent shutdown guard test: 9 passed. |
| Mounted in-app permission controls and Host/terminal workflows | 22 passed. Includes Host Allow/Ask/Deny, policy change during save preparation, exact targets and stale replies. |
| Registered AI/context + policy group | 7 passed, including normal Allow, combined Ask, Deny and revocation. |
| Source/version/revision edit guards and registered byte-stream group | 47 passed. |
| Contract/SDK/CLI suite | 91 passed. Four prepared sample archives validate. |
| Lint | Zero errors, seven existing warnings. |
| Production build | Passed; final `pack:dir` build also passed in 76 seconds. |
| Contract/capability/Codex generation | Regenerated/checks clean; no capability-tool diff. |
| Full TypeScript check | Frozen baseline and implementation each have 774 diagnostics; zero added/removed after normalizing worktree paths. |
| Full `npm test` | Incomplete: stopped after three unchanged bridge child processes remained pending. Reported 9,891 tests, 9,359 passed, 40 failed, 478 cancelled, 14 skipped. This is not a passing aggregate. |
| Desktop `pack:dir` | Unpassed: native rebuild attempts `/home/agent/.electron-gyp`, outside this workspace's writable roots. Earlier pinned Electron installation failed with `fetch failed`. No shim, alternate runtime or sandbox change was used. |
Frozen-baseline focused runs reproduce the two large-history SFTP recovery
failures, six Markdown comparison failures, and missing-Electron adapter/serial
failures observed in the broad run. Other broad-run failures remain unclassified;
unchanged code alone does not prove a baseline failure. One earlier runtime
aggregate hit the existing 15-second byte-stream fixture timeout during concurrent
builds; its focused run and the final aggregate above pass that case.
Logs and archives are in `/workspace/permission-evidence/` in this execution
environment. The final task report identifies any remaining blockers and checks.
Exact-head CI and native acceptance remain pending parent coordination.
## Component screenshots
Chromium rendered the actual React components with built-in light/dark tokens
and synthetic host bridge data. These images demonstrate layout and state only;
they do not certify Electron behavior.
| Controls | Ask |
| --- | --- |
| ![Light controls](evidence/permission-modes/plugin-permission-controls-light.png) | ![Light Ask](evidence/permission-modes/plugin-permission-ask-light.png) |
| ![Dark controls](evidence/permission-modes/plugin-permission-controls-dark.png) | ![Dark Ask](evidence/permission-modes/plugin-permission-ask-dark.png) |
## Prepared sample packages
All packages were built locally; none was activated in a native app. No owned or
public database service was contacted. The owned database package is scoped to
the previously specified disposable fixture endpoint `tcp://10.77.0.20:3306`.
| Archive | SHA-256 |
| --- | --- |
| assistant.ncpkg | `c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395` |
| host-actions.ncpkg | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` |
| terminal-selection-draft.ncpkg | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` |
| database-owned.ncpkg | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` |
Reproduction uses the unchanged sample build tools:
```sh
node examples/plugins/assistant/build.mjs
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant.ncpkg
node packages/plugin-cli/dist/cli.js pack examples/plugins/host-actions --out /tmp/host-actions.ncpkg
node examples/plugins/terminal-selection-draft/prepare.mjs /tmp/terminal-selection-draft.ncpkg
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-owned.ncpkg
```
## Parent-serialized native recheck
1. Use a fresh isolated app profile and the exact next integrated head. Import and
enable a sample: no installation permission ceremony should appear.
2. In Allow, select hosts in the host chooser, create/edit via the normal draft
editor, and connect using a current selected reference without a separate
plugin confirmation. Ordinary SSH authentication and host-key checks remain.
3. Select a Note/context and model, start and read a turn in Allow. Then switch
AI/content to Ask: one scoped decision covers the turn and exact references,
with no per-chunk or second content-sharing dialog.
4. Check terminal Allow sharing and the editable inert draft; Ask shows one share
preview; Deny releases no selection and cannot reopen old handles.
5. Check network Allow/Ask/Deny at the declared endpoint. Preserve all endpoint,
DNS, redirect, private-network and control-plane rejection checks.
6. Flip policy while a chooser, draft, model read or network operation awaits.
Old results/tokens must fail. Switch back to Allow and start a fresh operation.
7. Restart/update the same source with an explicit Deny; it must remain Deny.
Check distinct plugins and sources, stale settings edits and explicit source
reset. Review bounded audit events and optional local logging for redaction.
No merge, release or native acceptance is authorized by this handoff.
@@ -1,12 +1,53 @@
# Plugin security and permission boundary
Status: phase 3 internal preview (`0.1.0-internal`)
Status: unreleased plugin platform; browser plugins remain sandboxed. Advanced
utility packages remain restricted to developer mode.
This phase is available only with `NETCATTY_PLUGIN_DEV=1`. It is deliberately
usable by later contribution and Provider phases, but it is not a public plugin
release. There is no renderer permission UI yet. The first-party development
bootstrap injects a native Electron confirmation dialog; embedders that do not
inject a decision provider still fail closed.
Declared capabilities that the platform exposes default to **Allow**. Installing
or enabling a plugin does not open a permission checklist. In Settings, each
plugin has host-owned **Allow / Ask / Deny** controls with its declared resource
scope. Allow skips permission dialogs; Ask requests one scoped decision; Deny
rejects before side effects or content release. Target selection, model selection,
and editing/saving drafts remain normal host workflows. SSH authentication and
host-key checks are unchanged.
## Policy storage, updates and source changes
Policies live in `plugin_host_preferences`, separate from plugin-writable
settings, under `permission:<host source owner>:<capability>`. Missing entries mean
Allow. No historical plugin-policy migration or unrelated user-data reset runs.
Policies survive restart, disable/re-enable, uninstall/reinstall from the same
source, and package/version/publisher/declaration changes within that source.
An explicit Deny therefore survives an update, including removal and later
reintroduction of that capability. New declared capabilities default to Allow;
existing Ask/Deny modes apply to the current declared scope. Undeclared
permissions, unsupported methods and out-of-scope resources still fail closed.
Changing a source requires the existing explicit uninstall/private-data reset.
That reset removes only the old source's plugin policies and private records.
A different source receives independent defaults. Policies never come from a
manifest, plugin setting, RPC payload or imported grant. Existing grant hashes
still include the immutable package principal and declaration boundaries; this
change does not turn an old package grant into a cross-version grant.
Every policy edit clears saved grants, cancels pending decisions and active RPCs,
and invalidates host resource handles and model/stream state. Guards bind the
source and policy revision before asynchronous authorization resolution, recheck
before commits and content delivery, and remain invalid after a Deny-to-Allow
flip. A late prompt reply cannot revive old work. The user must start a fresh
operation after changing policy.
Ask uses Netcatty's existing in-app Dialog, typography and theme. Only the trusted
main window can receive requests or return scoped single-use tokens; close,
cancel, timeout, lock, hide, reload, crash and stale runtime/source fail closed.
A plugin DOM click never becomes a host gesture. Choosers, drafts, terminal and
clipboard sharing use their existing host-owned action tokens. Ask integrates
with that workflow instead of opening a second capability dialog. Allow skips
sharing and connection permission confirmations while retaining validation and
operation/resource binding. AI start combines model access and exact selected
context handles into one Ask decision; its receipt is limited to that operation.
Stream reads, stop and close validate broker handles without prompting per chunk.
Selected-context reads remain selected-only and never enumerate the whole Vault.
## Authority model
@@ -40,9 +81,9 @@ Permission prompts use the canonical contract directly: absent operation and
session IDs are omitted, long host-generated operation IDs become stable
SHA-256 identifiers, reasons are bounded, and no request can carry more than
128 canonical resources.
Runtime trust/placement resolves before permission prompts. The special
`runtime.advanced` permission is excluded from generic required-permission
preflight and requested exactly once only when the host actually selects the
Runtime trust/placement resolves before advanced-runtime authorization. Required
preflight validates declarations without asking for capability grants. The
`runtime.advanced` policy is checked only if the host selects the developer-only
utility runtime.
## Grant lifetimes
@@ -62,19 +103,19 @@ identical prompts coalesce. Prompt timeout, runtime abort,
cancel, denial, absence of a decision provider, and stale activation all fail
closed. Grant/use/deny/revoke events enter the bounded security audit.
`PermissionRequest` is part of the canonical Schema and carries plugin display
identity, version, runtime placement, permission, canonical resources and their
aligned resource kinds, reason, operation and optional host session. This is
the complete PR-4 UI handoff; the renderer must return the same request ID and
one canonical lifetime decision. The native fallback visibly escapes control,
line-separator, and bidirectional-control characters in every plugin-controlled
display field so untrusted text cannot forge labels or resource lines.
`PermissionRequest` remains part of the canonical schema. It carries host-bound
identity, capability, canonical resource scope and optional related capabilities
for one combined operation. The trusted host UI renders text, never plugin HTML
or body payloads. A response must match its request and action token. Ordinary
Ask decisions apply once; long-lived stream attachment may use a host session.
The older grant lifetimes remain supported by the engine, not exposed as a
mandatory install flow.
## Host-mediated capabilities
These brokers are the only authority path for ordinary browser plugins. An
advanced utility entrypoint is intentionally different: `runtime.advanced`
means explicit consent to ambient Node, filesystem and network APIs in its
exposes ambient Node, filesystem and network APIs in its
contained process. Fine-grained broker grants do not sandbox that ambient Node
authority. Phase 9 must also require a verified publisher principal before the
advanced path can be publicly enabled.
@@ -83,8 +124,7 @@ Required resource-scoped permissions (`network`, filesystem read/write, and
companion execution) must declare non-empty activation-time resource bounds;
the all-resources `*` wildcard is not a valid bound.
The string shorthand remains available only for optional declarations, whose
concrete resource is approved on first use. A package update therefore cannot
activate first and defer a newly required resource decision until later.
concrete resource is checked against its policy and scope on use.
Native companions are an advanced-runtime capability. Their manifests require
a Node utility entrypoint plus `runtime.advanced`, and `companion.start` rejects
@@ -229,8 +269,8 @@ after durable user data can exist.
## Inspect and revoke actual grants
The installed package detail separates declared permissions from **Saved
grants**. The host-only panel lists resource, scope, session (when applicable),
The installed package detail shows declared capabilities, resources and effective
Allow / Ask / Deny modes alongside any saved grants. The host-only panel lists resource, scope, session (when applicable),
and grant time. Each row indicates whether its declaration hash and security
principal match the host-owned current activation. Saved grants from an older
package or a stopped/disabled activation remain visible as inactive. One-use decisions do not appear as retained grants. Refresh
@@ -244,7 +284,7 @@ persists disabled state before revoking grants, invalidates contributions, and
waits for runtime shutdown. Existing runtime teardown cancels RPC, streams,
leases, providers, and companions. Every cleanup stage is attempted if grant deletion, audit persistence, or
contribution invalidation fails. Multiple errors are aggregated and reported;
the UI never claims successful revocation when persistence or cleanup failed. Explicit enable requests fresh access.
the UI never claims successful revocation when persistence or cleanup failed. Explicit enable retains the saved policy; Deny remains in force.
This management bridge is available only to the trusted app renderer. It is
not a plugin RPC method and grants no plugin authority over another package.
+3 -3
View File
@@ -11,12 +11,12 @@ const PERMISSION_DESCRIPTIONS = {
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and separately approved selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和另行批准的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and permitted selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和权限允许的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
"context.selected.list": [true, "Selected context summaries", "Ask you to share summaries of selected workspace, tab, session, host, or Note references. Does not read their text.", "所选上下文摘要", "请求你分享所选工作区、标签页、会话、主机或笔记引用的摘要。此权限不会读取其文本。"],
"context.selected.read": [true, "Read selected context", "Read text from references you explicitly select and approve. It may contain private data.", "读取所选上下文", "读取你明确选择并批准的引用中的文本,其中可能包含隐私信息。"],
"context.selected.read": [true, "Read selected context", "Read text from host-selected references under this plugin's permission setting. It may contain private data.", "读取所选上下文", "根据插件权限设置读取宿主所选引用中的文本,其中可能包含隐私信息。"],
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
"terminal.selection.read": [true, "Share terminal selection", "Read only text explicitly previewed and shared from the selected live terminal. It may contain secrets. Each action requires approval.", "分享终端选区", "仅读取用户从当前活跃终端明确预览并分享的文本,可能包含秘密;每次操作均需确认。"],
"terminal.selection.read": [true, "Share terminal selection", "Read only the current selection from the host-selected live terminal. It may contain secrets. Ask mode shows one sharing preview.", "分享终端选区", "仅读取宿主所选活跃终端的当前选区,可能包含秘密;询问模式显示一次分享预览。"],
"terminal.input.propose": [true, "Review terminal input draft", "Open an editable host draft for one selected terminal. Does not send input or Enter.", "审阅终端输入草稿", "打开所选终端的可编辑宿主草稿,不发送输入或回车。"],
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
"terminal.output": [true, "Read terminal output", "Receive terminal output through supported providers; it may contain credentials or private data.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
+1 -14
View File
@@ -153,9 +153,6 @@ function createBridgeRegistrar(context) {
env: process.env,
createService: () => {
const { createPluginHostService } = require("../plugins/hostService.cjs");
const {
createNativePermissionDecisionProvider,
} = require("../plugins/nativePermissionDecision.cjs");
const {
terminalInterceptorChoiceLabel,
terminalInterceptorIdentifier,
@@ -169,17 +166,6 @@ function createBridgeRegistrar(context) {
getTerminalWorkerManager: () => terminalWorkerManager,
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
requestPermissionDecision: createNativePermissionDecisionProvider({
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
dialog: electronModule.dialog,
window: win,
getWindow: () => {
const settingsWindow = getWindowManager().getSettingsWindow?.();
return settingsWindow && !settingsWindow.isDestroyed() && settingsWindow.isFocused()
? settingsWindow
: win;
},
}),
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
requestTerminalInterceptorSelection: async ({ direction, providers }) => {
const messages = terminalInterceptorMessages(getWindowManager().getCurrentLanguage?.() ?? "en");
@@ -223,6 +209,7 @@ function createBridgeRegistrar(context) {
manager: pluginHostService?.manager,
interactionService: pluginHostService?.interactionService,
permissionEngine: pluginHostService?.permissionEngine,
permissionDecisionHost: pluginHostService?.permissionDecisionHost,
runtimeSupervisor: pluginHostService?.runtimeSupervisor,
database: pluginHostService?.database,
catalogService: pluginHostService?.catalogService,
@@ -28,7 +28,7 @@ if (!schema.$defs.PluginAIChatResult) {
},
requestPermissionDecision: async (request) => {
decisions.push(request);
if ((request.permission === "context.selected.read" && !contentAllowed) || (request.permission === "ai.invoke" && !modelAllowed)) return { requestId: request.requestId, decision: "deny" };
if (((request.permission === "context.selected.read" || request.relatedPermissions?.some((item) => item.permission === "context.selected.read")) && !contentAllowed) || (request.permission === "ai.invoke" && !modelAllowed)) return { requestId: request.requestId, decision: "deny" };
return { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
},
});
@@ -45,14 +45,22 @@ if (!schema.$defs.PluginAIChatResult) {
await service.manager.install(archive, { enable: true });
const id = "com.netcatty.assistant";
const run = (name, args) => service.contributionService.executeCommand(`${id}.${name}`, args);
modelAllowed = false; await assert.rejects(run("open")); assert.equal(invoked, 0);
modelAllowed = true;
// Normal installed Allow flow has only host model/context selection, with
// no permission or body-sharing ceremony at any stream stage.
const defaultChat = await run("open"); const [defaultReference] = await run("contexts");
const defaultTurn = await run("send", { chatHandle: defaultChat.chatHandle, prompt: "Summarize", contexts: [{ handle: defaultReference.handle, version: defaultReference.version }] });
await run("read", { turnHandle: defaultTurn.turnHandle }); await run("read", { turnHandle: defaultTurn.turnHandle }); await run("read", { turnHandle: defaultTurn.turnHandle });
assert.equal(decisions.length, 0); assert.equal(invoked, 1);
invoked = 0;
service.permissionEngine.setMode({ id, manifest: service.database.getActivePlugin(id).manifest }, "ai.invoke", "deny");
await assert.rejects(run("open")); assert.equal(invoked, 0);
service.permissionEngine.setMode({ id, manifest: service.database.getActivePlugin(id).manifest }, "ai.invoke", "ask");
const chat = await run("open"); const references = await run("contexts"); assert.equal(references.length, 1);
assert.equal(references[0].scope, "note"); assert.equal(references[0].text, undefined);
const input = { chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: references[0].handle, version: references[0].version }] };
service.database.setPermissionMode(id, "context.selected.read", "ask");
await assert.rejects(run("send", input)); assert.equal(invoked, 0);
contentAllowed = true; reviewed = false; await assert.rejects(run("send", input)); assert.equal(invoked, 0);
reviewed = true;
contentAllowed = true; reviewed = false; // No separate body-sharing ceremony.
const turn = await run("send", input);
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "text", sequence: 0, text: "Synthetic summary" });
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "usage", sequence: 1, inputTokens: 12, outputTokens: 6, totalTokens: 18 });
@@ -64,7 +72,7 @@ if (!schema.$defs.PluginAIChatResult) {
service.permissionEngine.revokeApplication(id, "ai.invoke", "*");
assert.equal(service.aiContextService.broker.turns.size, 0);
await assert.rejects(run("read", { turnHandle: pending.turnHandle }));
assert.ok(decisions.some((decision) => decision.permission === "context.selected.read" && decision.resources[0].startsWith("selected:")));
assert.ok(decisions.some((decision) => decision.relatedPermissions?.some((item) => item.permission === "context.selected.read" && item.resources[0].startsWith("selected:"))));
const fresh = await run("open"); const [reference] = await run("contexts");
versionCurrent = false;
await assert.rejects(run("send", { chatHandle: fresh.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }));
+13 -3
View File
@@ -97,7 +97,17 @@ class PluginAIContextService {
authorization: async (params, context) => {
await this.#guard(context);
if (route.operation === "readSelected") await this.selected.assertCurrent(params, context);
return this.#authorization(route.permission, params);
if (route.operation === "start") {
for (const reference of params.contexts ?? []) await this.selected.assertCurrent(reference, context);
}
return { ...this.#authorization(route.permission, params),
...(route.operation === "start" && params.contexts?.length ? { relatedPermissions: params.contexts.map((reference) => (
this.#authorization("context.selected.read", reference)
)) } : {}),
// Open selects a model; start is the meaningful invocation. The
// broker's source-bound handles authorize subsequent stream work.
...(["open", "read", "stop", "close"].includes(route.operation) ? { deferDecision: true } : {}),
};
},
}));
}
@@ -150,8 +160,8 @@ class PluginAIContextService {
await this.#guard(context);
const selected = await this.selected.read(reference, context);
await this.#guard(context);
if (!await this.host.reviewText({ scope: selected.scope, title: selected.title, version: selected.version,
text: selected.text, pluginId: context.pluginId }, context)) throw fail("cancelled", "Selected context sharing was cancelled");
// The selected handle and version are checked on both sides of the read.
// Permission middleware (or the model-turn content check above) owns Ask.
await this.#guard(context);
await this.selected.assertCurrent(reference, context);
return selected;
@@ -22,26 +22,23 @@ function fixture() {
allow: (v) => { allowed = v; }, review: (v) => { reviewed = v; }, current: (v) => { current = v; }, source: (v) => { sourceId = v; }, revoke: () => revoke({ pluginId: context.pluginId }) };
}
test("model invocation independently requests content permission and reviewed text", async (t) => {
test("model invocation independently checks content permission without a duplicate sharing prompt", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
const { items: [reference] } = await f.service.list({}, f.context); assert.equal(f.reads(), 0);
const chat = await f.service.open({}, f.context);
f.allow(false);
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
assert.equal(f.reads(), 0); assert.equal(f.invoked(), 0);
f.allow(true); f.review(false);
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
assert.equal(f.invoked(), 0);
f.review(true);
f.allow(true); f.hostAdapter.reviewText = () => { throw new Error("Duplicate sharing prompt"); };
const turn = await f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context);
assert.equal((await f.service.read(turn, f.context)).text, "Result");
assert.ok(f.authorizations.every((authorization) => authorization.permission === "context.selected.read" && authorization.allowedScopes[0] === "once"));
});
test("source change during host review suppresses text and inference", async (t) => {
test("source change during selected-content read suppresses text and inference", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
const { items: [reference] } = await f.service.list({}, f.context); const chat = await f.service.open({}, f.context);
f.hostAdapter.reviewText = async () => { f.source("source-b"); return true; };
f.service.selected.handles.get(reference.handle).readText = async () => { f.source("source-b"); return "Never release"; };
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
assert.equal(f.invoked(), 0);
});
@@ -110,6 +110,7 @@ export default { activate(context) {
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
const run = (name) => service.contributionService.executeCommand(`${id}.${name}`);
service.permissionEngine.setMode({ id, manifest }, "network.stream", "ask");
await assert.rejects(run("run"), /denied/u);
assert.equal(service.byteStreamService.broker.connections.size, 0);
allow = true;
+20
View File
@@ -335,6 +335,7 @@ class PluginDatabase {
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars", "plugin_sync_provider_bindings"]) {
this.db.prepare(`DELETE FROM ${table} WHERE plugin_id = ? OR plugin_id = ?`).run(owner, pluginId);
}
this.db.prepare("DELETE FROM plugin_host_preferences WHERE key LIKE ?").run(`permission:${owner}:%`);
this.db.prepare("DELETE FROM plugin_source_bindings WHERE plugin_id = ?").run(pluginId);
});
}
@@ -1170,6 +1171,25 @@ class PluginDatabase {
`).run(pluginId, pluginId);
}
// Host-owned policy preferences are deliberately separate from plugin-writable
// settings. Source ownership is stable across package versions; grants are not.
getPermissionMode(pluginId, permission) {
const key = `permission:${this.privateOwner(pluginId)}:${permission}`;
const row = this.db.prepare("SELECT value_json FROM plugin_host_preferences WHERE key = ?").get(key);
const mode = row ? parseJson(row.value_json, "permission mode") : "allow";
if (!["allow", "ask", "deny"].includes(mode)) throw new Error("Corrupt plugin permission mode");
return mode;
}
setPermissionMode(pluginId, permission, mode) {
const key = `permission:${this.privateOwner(pluginId)}:${permission}`;
this.transaction(() => {
this.db.prepare("INSERT INTO plugin_host_preferences(key,value_json,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at")
.run(key, JSON.stringify(mode), this.clock());
this.#appendSecurityAudit(pluginId, "permission.policy-edited", JSON.stringify({ permission, mode, sourceOwner: this.privateOwner(pluginId) }));
});
}
getHostThemeSelection() {
const row = this.db.prepare("SELECT value_json FROM plugin_host_preferences WHERE key = 'theme'").get();
return row ? parseJson(row.value_json, "host theme selection") : {formatVersion: 1, revision: 0, light: null, dark: null};
@@ -1099,6 +1099,13 @@
"request"
],
"properties": {
"permissionMode": {
"enum": [
"allow",
"ask",
"deny"
]
},
"requestId": {
"type": "string",
"maxLength": 128
@@ -5774,6 +5781,32 @@
"$ref": "#/$defs/PermissionResourceKind"
}
},
"relatedPermissions": {
"type": "array",
"maxItems": 5,
"items": {
"type": "object",
"required": [
"permission",
"resources"
],
"properties": {
"permission": {
"$ref": "#/$defs/PluginPermission"
},
"resources": {
"type": "array",
"minItems": 1,
"maxItems": 128,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/PermissionResource"
}
}
},
"additionalProperties": false
}
},
"reason": {
"type": "string",
"minLength": 1,
@@ -57,8 +57,7 @@ for (const source of ["compose", "share"]) test(`normal browser example complete
const output = source === "compose" ? "synthetic output" : "SYNTHETIC PRIVATE TEXT";
assert.deepEqual(result, { copied: true, characters: output.length });
assert.deepEqual(copied, [output]); assert.equal(reads, source === "share" ? 1 : 0);
assert.ok(requests.includes("ui.interact")); assert.ok(requests.includes("clipboard.write"));
assert.equal(requests.includes("clipboard.read"), source === "share");
assert.deepEqual(requests, [], "Allow mode has only the real choice/input workflows");
assert.equal(JSON.stringify(audit).includes("synthetic private text"), false);
assert.equal(JSON.stringify(audit).includes("synthetic output"), false);
assert.equal(service.pending.size, 0);
@@ -71,7 +70,7 @@ test("outer command deadline closes its real browser clipboard confirmation and
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
const identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-deadline", runtimeKind: "browser" };
const writes = [], messages = [];
const database = { listPermissionGrants: () => [], recordSecurityAudit() {},
const database = { getPermissionMode: () => "ask", listPermissionGrants: () => [], recordSecurityAudit() {},
listPlugins: () => [{ id: manifest.id, activeVersion: manifest.version, manifest, enabled: true, runtime: { status: "running" } }],
};
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once" }) });
+32 -4
View File
@@ -37,7 +37,7 @@ function interactionAuthorization(params, terminalTarget) {
if (params.kind === "terminal.selection.read" || params.kind === "terminal.input.propose") {
if (!terminalTarget) throw fail("failedPrecondition", "A host-selected terminal is required");
return { permission: params.kind, resources: [terminalTarget.resource],
reason: params.kind === "terminal.selection.read" ? "Preview and explicitly share the selected terminal text" : "Open an editable host draft without sending terminal input", operationId: params.kind, allowedScopes: ["once"] };
reason: params.kind === "terminal.selection.read" ? "Preview and explicitly share the selected terminal text" : "Open an editable host draft without sending terminal input", operationId: params.kind, allowedScopes: ["once"], deferDecision: true };
}
const permission = params.kind === "clipboard.read" ? "clipboard.read"
: params.kind === "clipboard.write" ? "clipboard.write" : "ui.interact";
@@ -46,6 +46,7 @@ function interactionAuthorization(params, terminalTarget) {
resources: [permission.startsWith("clipboard.") ? "clipboard:text" : "*"],
reason: permission.startsWith("clipboard.") ? "Explicitly share or replace clipboard text" : "Show a host input or choice dialog",
operationId: `ui:${params.kind}`,
deferDecision: true,
...(permission.startsWith("clipboard.") ? { allowedScopes: ["once"] } : {}),
};
}
@@ -174,6 +175,7 @@ class PluginHostInteractionService {
requestId: entry.id, actionToken: entry.actionToken,
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
permissionMode: entry.permissionMode,
...(entry.terminalTarget ? { terminalSessionId: entry.terminalTarget.sessionId, terminalRendererId: entry.terminalTarget.rendererId, terminalLabel: entry.terminalTarget.label } : {}),
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
...(this.vaultService?.isRequest(entry.params) ? this.vaultService.describePrompt(entry) : {}),
@@ -261,10 +263,25 @@ class PluginHostInteractionService {
? this.terminalSelection.resolve(params.handle, context) : undefined);
if (terminalTarget) this.terminalSelection.assertCurrent(terminalTarget);
if (params.kind === "terminal.input.propose") this.terminalSelection.resolve(params.handle, context);
const authorization = context.authorization ?? interactionAuthorization(params, terminalTarget);
const permissionMode = this.permissionEngine.getMode?.(context, authorization.permission) ?? "ask";
if (permissionMode === "deny") throw fail("permissionDenied", "Plugin permission is disabled");
// These actions need no target/data-input UI. The main process still owns
// admission, quotas, audit and precise operation/resource validation.
if (permissionMode === "allow" && params.kind.startsWith("clipboard.")) {
await context.assertActive(); this.#assertWindow(sender);
const entry = { context, params };
this.#audit(entry, "completed");
if (params.kind === "clipboard.write") { this.clipboard.writeText(params.text); return { cancelled: false, value: true }; }
const text = assertTextBytes(this.clipboard.readText());
this.quotaManager.chargeBytes(context.runtimeId, "clipboard", Buffer.byteLength(text));
await context.assertActive();
return { cancelled: false, value: text };
}
const now = this.clock();
return new Promise((resolve, reject) => {
const entry = {
id: randomUUID(), context, params, sender, resolve, reject, terminalTarget,
id: randomUUID(), context, params, sender, resolve, reject, terminalTarget, permissionMode,
expiresAt: now + this.timeoutMs, phase: "request", actionToken: null,
};
entry.onAbort = () => this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
@@ -273,8 +290,14 @@ class PluginHostInteractionService {
this.pending.set(entry.id, entry);
context.signal?.addEventListener("abort", entry.onAbort, { once: true });
if (context.signal?.aborted) { entry.onAbort(); return; }
try { this.#present(entry); }
catch { this.#finish(entry, null, fail("unavailable", "Host interaction window is unavailable")); }
try {
if (params.kind === "clipboard.read") {
entry.previewText = assertTextBytes(this.clipboard.readText());
this.quotaManager.chargeBytes(context.runtimeId, "clipboard", Buffer.byteLength(entry.previewText));
entry.phase = "preview";
}
this.#present(entry);
} catch { this.#finish(entry, null, fail("unavailable", "Host interaction window is unavailable")); }
});
}
@@ -324,6 +347,11 @@ class PluginHostInteractionService {
if (!text.length) throw fail("failedPrecondition", "Select terminal text before requesting a share");
this.#audit(entry, "preview-confirmed");
this.quotaManager.chargeBytes(entry.context.runtimeId, "terminal", Buffer.byteLength(text));
if (entry.permissionMode === "allow") {
this.#audit(entry, "completed");
return this.#finish(entry, { cancelled: false, value: Object.freeze({ text,
...this.terminalSelection.issue(entry.terminalTarget, entry.context) }) });
}
entry.previewText = text; entry.phase = "preview"; this.#present(entry); return true;
}
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
@@ -81,7 +81,7 @@ test("old command completion cannot invalidate a replacement runtime's host-owne
assert.deepEqual(await request, { cancelled: false, value: "synthetic new input" });
});
test("Once permission dialog may restore the same owner's focus after its decision resolves", async (t) => {
test("input workflow is shown directly without a separate capability prompt", async (t) => {
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const f = fixture(t);
@@ -104,13 +104,13 @@ test("Once permission dialog may restore the same owner's focus after its decisi
}).requestHandlers["ui.interact"];
const result = route({ kind: "input", title: "Synthetic input", initialValue: "visible" }, { signal: f.context.signal });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(f.messages.length, 0, "no dialog is delivered while the owner is backgrounded");
assert.equal(f.messages.filter((message) => !message.closed).length, 1);
await new Promise((resolve) => setTimeout(resolve, 45));
const prompt = f.messages.find((message) => !message.closed);
assert.ok(prompt);
await f.respond(prompt, "submit", "visible");
assert.deepEqual(await result, { cancelled: false, value: "visible" });
assert.equal(decisions, 1);
assert.equal(decisions, 0);
assert.equal(f.reads, 0); assert.deepEqual(f.writes, []);
});
@@ -142,17 +142,12 @@ test("focus restoration is bounded and admission cannot survive owner or access
await assert.rejects(background.service.request(assertInteractionRequest({ kind: "input", title: "Background" }), background.context), /focused unlocked/);
});
test("clipboard read waits for preview action and a separate single-use sharing confirmation", async (t) => {
test("Ask clipboard read has one scoped preview and a single-use sharing decision", async (t) => {
const f = fixture(t);
const request = await f.begin({ kind: "clipboard.read", title: "Share text" });
let done = false; void request.promise.then(() => { done = true; });
assert.equal(f.reads, 0);
await f.respond(request.prompt, "preview");
const preview = f.messages.filter((message) => !message.closed).at(-1);
const preview = request.prompt;
assert.equal(preview.phase, "preview"); assert.equal(preview.previewText, "synthetic clipboard");
assert.notEqual(preview.actionToken, request.prompt.actionToken);
assert.equal(f.reads, 1); assert.equal(done, false);
assert.equal(await f.respond(request.prompt, "submit"), false, "old-phase action cannot share text");
assert.equal(f.reads, 1);
assert.equal(await f.respond(preview, "submit"), true);
assert.deepEqual(await request.promise, { cancelled: false, value: "synthetic clipboard" });
assert.equal(await f.respond(preview, "submit"), false, "no replay");
@@ -221,7 +216,7 @@ test("manifest permission checks and interaction quota run before any host promp
const f = fixture(t);
let decisions = 0;
const engine = new PluginPermissionEngine({
database: { listPermissionGrants: () => [], recordSecurityAudit() {} },
database: { getPermissionMode: () => "deny", listPermissionGrants: () => [], recordSecurityAudit() {} },
requestDecision: async (request) => { decisions++; return { requestId: request.requestId, decision: "deny" }; },
});
t.after(() => engine.shutdown());
@@ -234,7 +229,7 @@ test("manifest permission checks and interaction quota run before any host promp
assert.equal(decisions, 0); assert.equal(f.messages.length, 0);
const route = registry.createRoutes({ ...base, manifest: { ...base.manifest, permissions: { optional: ["ui.interact"] } } }).requestHandlers["ui.interact"];
for (let count = 1; count < 10; count++) {
await assert.rejects(route({ kind: "input", title: "Denied" }, { signal: f.context.signal }), /denied/);
await assert.rejects(route({ kind: "input", title: "Denied" }, { signal: f.context.signal }), /disabled/);
}
const before = decisions;
await assert.rejects(route({ kind: "input", title: "Over quota" }, { signal: f.context.signal }), /limit/);
@@ -270,7 +265,7 @@ test("same-document navigation keeps ownership, while document reload and render
if (event === "reload") f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
else f.sender.emit("render-process-gone");
await assert.rejects(request.promise, /window closed/);
assert.equal(f.reads, 0); assert.equal(f.service.owner, null);
assert.equal(f.reads, 1); assert.equal(f.service.owner, null);
}
});
@@ -278,7 +273,6 @@ test("host lock subscription cancels previewed text before it can be shared", as
let changed;
const f = fixture(t, { subscribeLock(listener) { changed = listener; return () => {}; } });
const request = await f.begin({ kind: "clipboard.read", title: "Share" });
await f.respond(request.prompt, "preview");
const preview = f.messages.filter((message) => !message.closed).at(-1);
changed({ locked: true });
await assert.rejects(request.promise, /cancelled/);
+14
View File
@@ -171,7 +171,12 @@ class PluginHostRpcRegistry {
const middleware = [...this.middleware];
const streamHandlers = [...this.incomingStreams];
const createHandler = (kind, method, registration) => async (params, transportContext) => {
const operationController = new AbortController();
const signal = transportContext.signal ? AbortSignal.any([transportContext.signal, operationController.signal]) : operationController.signal;
const authorizationGuards = [];
const assertActive = async () => {
signal.throwIfAborted();
for (const guard of authorizationGuards) guard();
transportContext.signal?.throwIfAborted();
await assertCurrent?.();
transportContext.signal?.throwIfAborted();
@@ -181,6 +186,7 @@ class PluginHostRpcRegistry {
let auditResources;
let auditResult;
let auditError;
const authorizationCleanup = [];
try {
await assertActive();
const validatedParams = registration.validateParams
@@ -192,6 +198,9 @@ class PluginHostRpcRegistry {
const authorizationContext = Object.freeze({
...transportContext,
...identity,
signal,
addAuthorizationGuard: (guard) => authorizationGuards.push(guard),
cancelAuthorization: (reason) => operationController.abort(reason),
assertActive,
kind,
method,
@@ -201,6 +210,10 @@ class PluginHostRpcRegistry {
},
params: validatedParams,
});
for (const current of middleware) {
const cleanup = current.prepareAuthorization?.(authorizationContext);
if (typeof cleanup === "function") authorizationCleanup.push(cleanup);
}
const resolvedAuthorization = typeof registration.authorization === "function"
? await registration.authorization(validatedParams, authorizationContext)
: registration.authorization;
@@ -230,6 +243,7 @@ class PluginHostRpcRegistry {
auditError = error;
throw error;
} finally {
for (const cleanup of authorizationCleanup) cleanup();
// Never record params, handler results, error messages or plugin text.
const dataBytes = (value) => {
if (typeof value === "string") return Buffer.byteLength(value);
+2 -1
View File
@@ -76,7 +76,8 @@ test("host RPC middleware receives immutable host identity, method metadata, and
assert.equal(Object.isFrozen(seen[0].metadata.permission.resources), true);
assert.deepEqual(seen[0].params, { hostId: "host-1" });
assert.equal(seen[0].requestId, 7);
assert.equal(seen[0].signal, signal);
assert.equal(seen[0].signal.aborted, signal.aborted);
assert.equal(typeof seen[0].addAuthorizationGuard, "function");
assert.equal(typeof seen[0].assertActive, "function");
});
+8 -1
View File
@@ -105,6 +105,7 @@ function createPluginHostService(options) {
let byteStreamService;
let aiContextService;
let aiHostAdapter;
let permissionDecisionHost;
const logService = new PluginLogService({ paths, electron: options.electron, resolveProvenance: options.resolvePluginProvenance ?? createPluginLogProvenanceResolver(database) });
try {
const packageStore = new PackageStore({
@@ -128,9 +129,12 @@ function createPluginHostService(options) {
runtimeDirectory,
moduleResources,
});
const { PluginPermissionDecisionHost } = require("./permissionDecisionHost.cjs");
permissionDecisionHost = new PluginPermissionDecisionHost({ getWindow: options.getInteractionWindow,
isLocked: options.isAppLocked, subscribeLock: options.subscribeAppLock });
const permissionEngine = new PluginPermissionEngine({
database,
requestDecision: options.requestPermissionDecision,
requestDecision: options.requestPermissionDecision ?? ((...args) => permissionDecisionHost.request(...args)),
});
const quotaManager = new PluginQuotaManager({
getProcessMetrics: options.getProcessMetrics
@@ -456,6 +460,7 @@ function createPluginHostService(options) {
await viewHost?.shutdown();
await companionSupervisor.shutdown();
leaseStore.shutdown();
permissionDecisionHost.shutdown();
permissionEngine.shutdown();
quotaManager.shutdown();
} finally {
@@ -488,6 +493,7 @@ function createPluginHostService(options) {
byteStreamService,
aiContextService,
permissionEngine,
permissionDecisionHost,
quotaManager,
rpcRegistry,
runtimeSupervisor,
@@ -497,6 +503,7 @@ function createPluginHostService(options) {
viewHost,
};
} catch (error) {
try { permissionDecisionHost?.shutdown(); } catch {}
try { interactionService?.shutdown(); } catch {}
try { byteStreamService?.shutdown(); } catch {}
try { aiContextService?.shutdown(); } catch {}
+14 -40
View File
@@ -114,7 +114,7 @@ test("runtime cleanup revokes leases before awaiting companion teardown", async
assert.equal(settled, true);
});
test("runtime trust placement precedes required and advanced permission prompts", async (context) => {
test("runtime trust placement and default Allow require no install permission ceremony", async (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const events = [];
@@ -146,11 +146,7 @@ test("runtime trust placement precedes required and advanced permission prompts"
securityPrincipal: "verified:test-publisher-key",
signal: new AbortController().signal,
}), "utility");
assert.deepEqual(events, [
"placement",
"permission:storage",
"permission:runtime.advanced",
]);
assert.deepEqual(events, ["placement"]);
});
test("first-party placement selects the utility runtime for companion manifests", async (context) => {
@@ -198,7 +194,7 @@ test("first-party placement selects the utility runtime for companion manifests"
securityPrincipal: "unsigned-package:companion-placement",
signal: new AbortController().signal,
}), "utility");
assert.deepEqual(requested, ["companion.execute", "runtime.advanced"]);
assert.deepEqual(requested, []);
});
test("first-party placement selects utility for dual-entrypoint terminal interceptors", async (context) => {
@@ -238,15 +234,7 @@ test("first-party placement selects utility for dual-entrypoint terminal interce
securityPrincipal: "unsigned-package:interceptor-placement",
signal: new AbortController().signal,
}), "utility");
assert.deepEqual(requested.map((request) => request.permission), [
"provider.terminal",
"terminal.intercept.input",
"runtime.advanced",
]);
assert.match(
requested.find((request) => request.permission === "terminal.intercept.input")?.reason ?? "",
/arbitrary no-echo input may not be detectable/u,
);
assert.deepEqual(requested, []);
});
test("secure host methods fail closed without an approver while public logging remains available", async (context) => {
@@ -254,6 +242,7 @@ test("secure host methods fail closed without an approver while public logging r
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const service = createPluginHostService(createOptions(root));
context.after(() => service.manager.shutdown());
service.database.getPermissionMode = () => "ask";
const routes = service.rpcRegistry.createRoutes({
pluginId: "com.example.service",
pluginVersion: "1.0.0",
@@ -268,7 +257,7 @@ test("secure host methods fail closed without an approver while public logging r
});
await assert.rejects(
routes.requestHandlers["storage.set"]({ key: "answer", value: 42 }, transportContext()),
/not granted/,
/decision provider failed/,
);
await routes.notificationHandlers["log.write"]({ level: "info", message: "hello" }, transportContext());
assert.equal(service.database.getValue("com.example.service", "answer"), undefined);
@@ -303,6 +292,7 @@ test("approved host capabilities reuse application grants through the registry",
archiveSha256: "a".repeat(64),
packageRelativePath: "com.example.service/1.0.0/package",
});
service.permissionEngine.setMode({ id: pluginManifest.id, manifest: pluginManifest }, "storage", "ask");
const routes = service.rpcRegistry.createRoutes({
pluginId: "com.example.service",
pluginVersion: "1.0.0",
@@ -456,28 +446,12 @@ test("logging always drains before database close when another shutdown stage re
assert.equal(record.operation, "synthetic.before-shutdown");
});
test("activation permission denial is audited before a runtime logger exists", async (t) => {
const { defaultSecurityPrincipal } = require("./permissionResources.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-activation-log-"));
const host = createPluginHostService(createOptions(root));
test("required browser capabilities activate without a permission ceremony", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-activation-"));
let prompts = 0;
const host = createPluginHostService({ ...createOptions(root), requestPermissionDecision: () => { prompts++; throw new Error("Unexpected install prompt"); } });
t.after(async () => { await host.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
const manifest = { manifestVersion: 1, id: "com.example.synthetic-activation", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" }, permissions: { required: ["commands"] } };
const archiveSha256 = "a".repeat(64);
host.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256, packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
await host.logService.ready;
await host.logService.configure({ saveToDisk: true, verbose: false });
await assert.rejects(host.runtimeSupervisor.resolveRuntimeKind({
plugin: host.database.getActivePlugin(manifest.id), availableKinds: ["browser"], signal: new AbortController().signal,
securityPrincipal: defaultSecurityPrincipal(manifest, archiveSha256),
}), { code: RPC_ERRORS.permissionDenied });
await host.logService.sink.flush();
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "runtime.authorization");
assert.equal(record.authorization, "denied");
assert.equal(record.runtimeId, null);
assert.equal(record.errorCode, RPC_ERRORS.permissionDenied);
assert.equal(record.archiveSha256, archiveSha256);
assert.equal(Object.hasOwn(record, "message"), false);
assert.equal(host.runtimeSupervisor.getRuntimeIdentity(manifest.id), null);
const manifest = { id: "com.example.synthetic-activation", version: "1.0.0", publisher: "example", main: { browser: "index.js" }, permissions: { required: ["commands"] } };
assert.equal(await host.runtimeSupervisor.resolveRuntimeKind({ plugin: { id: manifest.id, activeVersion: manifest.version, manifest }, availableKinds: ["browser"], signal: new AbortController().signal }), "browser");
assert.equal(prompts, 0);
});
+2 -1
View File
@@ -47,7 +47,7 @@ class PluginHostVaultService {
this.admissions.set(params, binding);
return {
permission: method === "vault.requestHostDraft" ? "vault.write" : method === "vault.selectHosts" ? "vault.metadata" : "ui.interact",
resources: ["*"], allowedScopes: ["once"],
resources: ["*"], allowedScopes: ["once"], deferDecision: true,
reason: method === "vault.selectHosts" ? "Choose the current host metadata to share" : method === "vault.requestHostDraft" ? "Review a host draft before saving" : "Review a saved host connection and its script disclosure",
operationId: method,
};
@@ -61,6 +61,7 @@ class PluginHostVaultService {
if (this.admissions.get(params) !== binding) throw fail("permissionDenied", "Host action admission is unavailable");
this.admissions.delete(params);
const assertActive = async () => {
await originalContext.assertActive();
if (await this.sourceBinding.resolve(originalContext) !== binding) throw fail("permissionDenied", "Host action source changed");
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
};
+102
View File
@@ -0,0 +1,102 @@
"use strict";
const { randomBytes, timingSafeEqual } = require("node:crypto");
const { permissionDenied } = require("./permissionEngine.cjs");
const { describePluginPermission } = require("../../domain/pluginPermissionDescriptions.cjs");
const { escapePermissionText } = require("./nativePermissionDecision.cjs");
const CHANNEL = "netcatty:plugins:permission-prompt";
/** Main-window-only Ask decisions. Tokens are bound to one pending request and
* consumed before any asynchronous work. Plugin windows never own this link. */
class PluginPermissionDecisionHost {
constructor(options) {
this.getWindow = options.getWindow;
this.isLocked = options.isLocked ?? (() => true);
this.pending = new Map();
this.owner = null;
this.closed = false;
this.unsubscribeLock = options.subscribeLock?.((state) => { if (state.locked) this.cancelAll(); });
}
setReady(sender, ready) {
if (this.closed) throw permissionDenied("Permission UI is closed");
if (sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.() || typeof ready !== "boolean") {
throw permissionDenied("Permission UI sender is invalid");
}
this.cancelAll();
this.cleanup?.(); this.cleanup = null;
this.owner = ready ? sender : null;
if (ready) {
const window = this.getWindow();
const cancel = () => this.cancelAll();
const release = () => { this.cancelAll(); this.owner = null; };
const navigate = (details, _url, inPlace, mainFrame) => {
if ((details?.isMainFrame ?? mainFrame) !== false && (details?.isSameDocument ?? inPlace) !== true) release();
};
sender.on?.("destroyed", release); sender.on?.("render-process-gone", release); sender.on?.("did-start-navigation", navigate);
window.on?.("hide", cancel); window.on?.("blur", cancel);
this.cleanup = () => {
sender.removeListener?.("destroyed", release); sender.removeListener?.("render-process-gone", release); sender.removeListener?.("did-start-navigation", navigate);
window.removeListener?.("hide", cancel); window.removeListener?.("blur", cancel);
};
}
return true;
}
assertOwner(sender = this.owner) {
const window = this.getWindow?.();
if (this.closed || !sender || sender !== this.owner || window?.webContents !== sender || sender.isDestroyed?.()
|| window.isDestroyed?.() || !window.isFocused?.() || !window.isVisible?.() || this.isLocked()) {
throw permissionDenied("Permission UI requires the focused unlocked main window");
}
}
request(request, { signal } = {}) {
signal?.throwIfAborted(); this.assertOwner();
if (!describePluginPermission(request.permission).known || this.pending.size >= 16) {
throw permissionDenied("Permission prompt is unavailable");
}
const sender = this.owner;
return new Promise((resolve, reject) => {
const actionToken = randomBytes(32).toString("base64url");
const finish = (decision, error) => {
if (!this.pending.delete(request.requestId)) return;
signal?.removeEventListener("abort", abort);
try { sender.send(CHANNEL, { requestId: request.requestId, closed: true }); } catch {}
if (error) reject(error); else resolve({ requestId: request.requestId, decision, ...(decision === "allow" ? { scope: request.allowedScopes && !request.allowedScopes.includes("once") && request.sessionId && request.allowedScopes.includes("session") ? "session" : "once" } : {}) });
};
const abort = () => finish(null, permissionDenied("Permission prompt was cancelled"));
this.pending.set(request.requestId, { sender, actionToken, finish });
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) { abort(); return; }
try {
sender.send(CHANNEL, { ...request, actionToken,
...(request.pluginName ? { pluginName: escapePermissionText(request.pluginName) } : {}),
resources: (request.resources ?? []).map((resource) => escapePermissionText(resource)),
...(request.relatedPermissions ? { relatedPermissions: request.relatedPermissions.map((item) => ({
permission: item.permission, resources: item.resources.map((resource) => escapePermissionText(resource)),
})) } : {}),
});
} catch { abort(); }
});
}
respond(sender, response) {
this.assertOwner(sender);
if (!response || Object.keys(response).some((key) => !["requestId", "actionToken", "decision"].includes(key))
|| !["allow", "deny", "cancel"].includes(response.decision) || typeof response.actionToken !== "string") {
throw permissionDenied("Permission response is invalid");
}
const entry = this.pending.get(response.requestId);
if (!entry || entry.sender !== sender) return false;
const expected = Buffer.from(entry.actionToken); const actual = Buffer.from(response.actionToken);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
entry.actionToken = "";
entry.finish(response.decision);
return true;
}
cancelAll() { for (const entry of [...this.pending.values()]) entry.finish("cancel"); }
shutdown() { if (this.closed) return; this.closed = true; this.cancelAll(); this.cleanup?.(); this.unsubscribeLock?.(); this.owner = null; }
}
module.exports = { PluginPermissionDecisionHost, PLUGIN_PERMISSION_PROMPT_CHANNEL: CHANNEL };
@@ -0,0 +1,52 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { EventEmitter } = require("node:events");
const { PluginPermissionDecisionHost } = require("./permissionDecisionHost.cjs");
function fixture(t) {
const messages = [];
let locked = false;
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (_channel, event) => messages.push(event) });
const window = Object.assign(new EventEmitter(), { webContents: sender, isVisible: () => true, isFocused: () => true, isDestroyed: () => false });
const host = new PluginPermissionDecisionHost({ getWindow: () => window, isLocked: () => locked });
host.setReady(sender, true); t.after(() => host.shutdown());
const request = { requestId: "host-request", pluginId: "com.example.ask", permission: "network", resources: ["https://example.com"], reason: "Fixed host reason" };
return { host, window, sender, messages, request, lock: () => { locked = true; } };
}
test("in-app Ask is owned by the main window with one scoped, single-use response token", async (t) => {
const f = fixture(t); const pending = f.host.request(f.request);
const prompt = f.messages[0];
assert.throws(() => f.host.respond({ id: 2 }, { requestId: prompt.requestId, actionToken: prompt.actionToken, decision: "allow" }), /focused unlocked/);
assert.equal(f.host.respond(f.sender, { requestId: prompt.requestId, actionToken: "forged", decision: "allow" }), false);
const response = { requestId: prompt.requestId, actionToken: prompt.actionToken, decision: "allow" };
assert.equal(f.host.respond(f.sender, response), true);
assert.deepEqual(await pending, { requestId: prompt.requestId, decision: "allow", scope: "once" });
assert.equal(f.host.respond(f.sender, response), false);
});
for (const lifecycle of ["abort", "hide", "blur", "reload", "crash", "unmount"]) test(`Ask ${lifecycle} closes pending authority and stale replies fail`, async (t) => {
const f = fixture(t); const controller = new AbortController();
const pending = f.host.request(f.request, { signal: controller.signal });
const prompt = f.messages[0]; const outcome = pending.catch(() => ({ decision: "cancel" }));
if (lifecycle === "abort") controller.abort();
if (lifecycle === "hide" || lifecycle === "blur") f.window.emit(lifecycle);
if (lifecycle === "reload") f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
if (lifecycle === "crash") f.sender.emit("render-process-gone");
if (lifecycle === "unmount") f.host.setReady(f.sender, false);
assert.equal((await outcome).decision, "cancel"); assert.equal(f.host.pending.size, 0);
assert.equal(f.messages.at(-1).closed, true);
const respond = () => f.host.respond(f.sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, decision: "allow" });
if (f.host.owner) assert.equal(respond(), false); else assert.throws(respond);
});
test("unsupported permission and locked host cannot open an Ask dialog", (t) => {
const f = fixture(t);
assert.throws(() => f.host.request({ ...f.request, permission: "unknown" }), /unavailable/);
f.lock(); assert.throws(() => f.host.request(f.request), /focused unlocked/);
assert.equal(f.messages.length, 0);
});
test("shutdown cannot be reopened by a late host-ready message", (t) => {
const f = fixture(t); f.host.shutdown();
assert.throws(() => f.host.setReady(f.sender, true), /closed/);
assert.throws(() => f.host.request(f.request), /focused unlocked/);
assert.equal(f.messages.length, 0);
});
+104 -29
View File
@@ -156,6 +156,9 @@ function immutableRequest(request) {
...request,
resources: Object.freeze([...request.resources]),
resourceKinds: Object.freeze([...request.resourceKinds]),
...(request.relatedPermissions ? { relatedPermissions: Object.freeze(request.relatedPermissions.map((item) => Object.freeze({
...item, resources: Object.freeze([...item.resources]),
}))) } : {}),
});
}
@@ -174,6 +177,9 @@ class PluginPermissionEngine {
this.sessionPromptControllers = new Map();
this.promptControllers = new Set();
this.revocationListeners = new Set();
this.policyRevisions = new Map();
this.policyInstanceId = randomUUID();
this.operationDecisions = new WeakMap();
this.closed = false;
this.shutdownController = new AbortController();
}
@@ -185,6 +191,7 @@ class PluginPermissionEngine {
}
#emitRevocation(event) {
this.policyRevisions.set(event.pluginId, (this.policyRevisions.get(event.pluginId) ?? 0) + 1);
const frozen = Object.freeze({ ...event });
for (const listener of this.revocationListeners) {
try { listener(frozen); } catch {}
@@ -322,6 +329,39 @@ class PluginPermissionEngine {
));
}
getMode(context, permission) {
assertPluginPermission(permission);
if (context.sourceOwner && this.database.privateOwner(context.pluginId) !== context.sourceOwner) {
throw permissionDenied("Plugin permission source changed");
}
return this.database.getPermissionMode?.(context.pluginId, permission) ?? "allow";
}
getPolicyRevision(plugin) {
return createHash("sha256").update(JSON.stringify([
this.policyInstanceId, this.database.privateOwner?.(plugin.id) ?? plugin.id,
plugin.activeVersion ?? null, this.policyRevisions.get(plugin.id) ?? 0,
])).digest("hex");
}
describePolicies(plugin) {
return [...normalizePermissionDeclarations(plugin.manifest).values()].map((declaration) => ({
permission: declaration.permission,
resources: declaration.resources.length ? [...declaration.resources] : ["*"],
mode: this.getMode({ pluginId: plugin.id }, declaration.permission),
}));
}
setMode(plugin, permission, mode) {
assertPluginPermission(permission);
if (!["allow", "ask", "deny"].includes(mode)) throw new TypeError("Plugin permission mode is invalid");
if (!normalizePermissionDeclarations(plugin.manifest).has(permission)) throw permissionDenied("Permission is undeclared");
this.database.setPermissionMode(plugin.id, permission, mode);
// Clear every old grant and invalidate pending/in-flight work before the
// next operation. A later Allow is a new operation, never a revived prompt.
this.revokeAll(plugin.id);
}
async authorize(context, descriptor) {
if (this.closed) throw permissionDenied("Plugin permission engine is unavailable");
context.signal?.throwIfAborted();
@@ -357,7 +397,37 @@ class PluginPermissionEngine {
const resources = resourceDescriptors.map(({ resource }) => resource);
const resourceKinds = resourceDescriptors.map(({ resourceKind }) => resourceKind);
const { declaration, declarationHash } = this.#assertDeclared(context, permission, resources);
if (this.#hasAllGrants(
const revision = this.policyRevisions.get(context.pluginId) ?? 0;
const mode = this.getMode(context, permission);
if (mode === "deny") {
this.database.recordSecurityAudit(context.pluginId, "permission.denied", { permission, resources, mode });
throw permissionDenied(`Plugin permission is disabled: ${permission}`);
}
if (descriptor.relatedPermissions !== undefined && (!Array.isArray(descriptor.relatedPermissions) || descriptor.relatedPermissions.length > 5)) {
throw permissionDenied("Related permission scope is invalid");
}
const related = (descriptor.relatedPermissions ?? []).map((item) => {
const relatedResources = normalizePermissionResources(item.permission, item.resources);
this.#assertDeclared(context, item.permission, relatedResources);
const relatedMode = this.getMode(context, item.permission);
if (relatedMode === "deny") {
this.database.recordSecurityAudit(context.pluginId, "permission.denied", { permission: item.permission, mode: relatedMode });
throw permissionDenied(`Plugin permission is disabled: ${item.permission}`);
}
return { permission: item.permission, resources: relatedResources, mode: relatedMode };
});
const relatedAsk = related.some((item) => item.mode === "ask");
const receipt = this.operationDecisions.get(context);
const operationApproved = receipt?.revision === revision && receipt.permissions.some((item) => (
item.permission === permission && resources.every((resource) => item.resources.includes(resource))
));
if ((mode === "allow" && !relatedAsk) || descriptor.deferDecision === true || operationApproved) {
this.database.recordSecurityAudit(context.pluginId, "permission.used", { permission, resources, mode,
runtimeId: context.runtimeId ?? null });
descriptor.validateBeforeGrant?.();
return Object.freeze({ declaration, resources, scope: "policy", mode });
}
if (!relatedAsk && this.#hasAllGrants(
context,
permission,
declarationHash,
@@ -397,6 +467,7 @@ class PluginPermissionEngine {
descriptor.operationId ?? null,
descriptor.sessionId ?? null,
descriptor.allowedScopes ?? null,
related,
]);
let pending = this.pending.get(pendingKey);
let ownsPrompt = false;
@@ -404,7 +475,7 @@ class PluginPermissionEngine {
ownsPrompt = true;
pending = this.#requestGrant(
context,
descriptor,
{ ...descriptor, relatedPermissions: related },
resources,
resourceKinds,
declaration,
@@ -415,9 +486,15 @@ class PluginPermissionEngine {
}
const grant = await pending;
context.signal?.throwIfAborted();
if ((this.policyRevisions.get(context.pluginId) ?? 0) !== revision || this.getMode(context, permission) !== mode) {
throw permissionDenied("Plugin permission policy changed");
}
if (!ownsPrompt && grant.scope === "once") {
return this.authorize(context, descriptor);
}
if (related.length || context.addAuthorizationGuard) this.operationDecisions.set(context, {
revision, permissions: [{ permission, resources }, ...related],
});
return Object.freeze({ declaration, resources, scope: grant.scope });
}
@@ -441,6 +518,7 @@ class PluginPermissionEngine {
permission: descriptor.permission,
resources,
resourceKinds,
...(descriptor.relatedPermissions?.length ? { relatedPermissions: descriptor.relatedPermissions.map(({ permission, resources }) => ({ permission, resources })) } : {}),
reason: descriptor.reason,
...(descriptor.operationId === undefined ? {} : { operationId: descriptor.operationId }),
...(descriptor.sessionId === undefined ? {} : { sessionId: descriptor.sessionId }),
@@ -611,34 +689,12 @@ class PluginPermissionEngine {
}
async authorizeRequired(plugin, options = {}) {
const declarations = normalizePermissionDeclarations(plugin.manifest);
const skipPermissions = new Set(options.skipPermissions ?? []);
const context = {
pluginId: plugin.id,
pluginVersion: plugin.activeVersion,
runtimeId: options.runtimeId ?? null,
manifest: plugin.manifest,
securityPrincipal: options.securityPrincipal,
signal: options.signal,
};
for (const declaration of declarations.values()) {
if (!declaration.required || skipPermissions.has(declaration.permission)) continue;
await this.authorize(context, {
permission: declaration.permission,
resources: declaration.resources.length ? declaration.resources : ["*"],
...(declaration.resources.length
&& (declaration.permission === "filesystem.read"
|| declaration.permission === "filesystem.write")
? { resourceKinds: declaration.resources.map(() => "directory") }
: {}),
reason: declaration.reason || `Required by ${plugin.id}`,
operationId: `activation:${plugin.activeVersion}`,
});
}
options.signal?.throwIfAborted();
normalizePermissionDeclarations(plugin.manifest);
}
createMiddleware() {
return async (context, next) => {
const middleware = async (context, next) => {
if (context.metadata.public === true) {
context.recordAuthorization?.("not-required");
return next();
@@ -649,13 +705,32 @@ class PluginPermissionEngine {
}
try {
await this.authorize(context, context.authorization);
await context.assertActive();
context.recordAuthorization?.("allowed");
const result = await next();
await context.assertActive();
return result;
} catch (error) {
if (error?.code === RPC_ERRORS.permissionDenied) context.recordAuthorization?.("denied");
throw error;
}
context.recordAuthorization?.("allowed");
return next();
};
middleware.prepareAuthorization = (context) => {
if (context.metadata.public === true) return undefined;
const revision = this.policyRevisions.get(context.pluginId) ?? 0;
const owner = this.database.privateOwner?.(context.pluginId);
const subscription = this.onDidRevoke((event) => {
if (event.pluginId === context.pluginId) context.cancelAuthorization?.(permissionDenied("Plugin access changed"));
});
context.addAuthorizationGuard?.(() => {
if ((this.policyRevisions.get(context.pluginId) ?? 0) !== revision
|| (owner !== undefined && this.database.privateOwner(context.pluginId) !== owner)) {
throw permissionDenied("Plugin permission policy or source changed");
}
});
return () => subscription.dispose();
};
return middleware;
}
listGrants(pluginId) {
+9 -8
View File
@@ -19,7 +19,11 @@ const { RPC_ERRORS } = require("./rpcRouter.cjs");
function createDatabase(context) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-permissions-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
return new PluginDatabase(path.join(root, "plugins.sqlite"));
const database = new PluginDatabase(path.join(root, "plugins.sqlite"));
// These tests exercise explicit Ask decisions and grant semantics. Default
// Allow and persisted policy behavior are covered in permissionModes.test.cjs.
database.getPermissionMode = () => "ask";
return database;
}
function manifest(permissions) {
@@ -47,7 +51,7 @@ function runtimeContext(pluginManifest, overrides = {}) {
};
}
test("permission declarations are not grants and no renderer fails closed", async (context) => {
test("Ask mode without a renderer fails closed", async (context) => {
const database = createDatabase(context);
const engine = new PluginPermissionEngine({ database });
const pluginManifest = manifest({ required: ["storage"] });
@@ -551,7 +555,7 @@ test("required preflight rejects missing or wildcard resource-scoped activation
database.close();
});
test("required preflight prompts for non-resource permissions and bounded resources", async (context) => {
test("required preflight validates declarations without permission ceremonies", async (context) => {
const database = createDatabase(context);
const requested = [];
const engine = new PluginPermissionEngine({
@@ -573,10 +577,7 @@ test("required preflight prompts for non-resource permissions and bounded resour
activeVersion: pluginManifest.version,
manifest: pluginManifest,
});
assert.deepEqual(requested, [
{ permission: "runtime.advanced", resources: ["*"] },
{ permission: "network", resources: ["https://example.com"] },
]);
assert.deepEqual(requested, []);
database.close();
});
@@ -604,7 +605,7 @@ test("required filesystem preflight preserves declared directory scope", async (
activeVersion: pluginManifest.version,
manifest: pluginManifest,
});
assert.deepEqual(requests[0].resourceKinds, ["directory"]);
assert.deepEqual(requests, []);
await engine.authorize(runtimeContext(pluginManifest), {
permission: "filesystem.read",
resources: [child],
+145
View File
@@ -0,0 +1,145 @@
"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 { EventEmitter } = require("node:events");
const { PluginDatabase } = require("./database.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const { PluginHostInteractionService } = require("./hostInteractionService.cjs");
const { PluginNetworkBroker } = require("./networkBroker.cjs");
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
const { OFFICIAL_SOURCE } = require("./sourceIdentity.cjs");
const manifest = { id: "com.example.policy", publisher: "example", version: "1.0.0",
permissions: { optional: ["storage", "ui.interact", "clipboard.read", "clipboard.write", { permission: "network", resources: ["https://example.com"] }] } };
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-policy-"));
const filename = path.join(root, "plugins.sqlite");
const database = new PluginDatabase(filename);
const decisions = [], prompts = [], writes = [];
let approve = true;
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => {
decisions.push(request); return { requestId: request.requestId, decision: approve ? "allow" : "deny", ...(approve ? { scope: "once" } : {}) };
} });
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (_channel, prompt) => prompts.push(prompt) });
const window = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const interactions = new PluginHostInteractionService({ database, permissionEngine: engine, getWindow: () => window, isLocked: () => false,
clipboard: { readText: () => "SYNTHETIC PRIVATE BODY", writeText: (text) => writes.push(text) }, quotaManager: { chargeBytes() {} } });
interactions.setReady(sender, true);
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware()); interactions.registerRpcCapabilities(registry);
const context = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-policy", runtimeKind: "browser", manifest };
const handlers = registry.createRoutes(context).requestHandlers;
const call = (method, params) => handlers[method](params, {});
const set = (permission, mode) => engine.setMode({ id: manifest.id, manifest }, permission, mode);
t.after(() => { interactions.shutdown(); engine.shutdown(); database.close(); fs.rmSync(root, { recursive: true, force: true }); });
return { database, filename, engine, registry, context, call, set, decisions, prompts, writes, interactions, sender, approve: (v) => { approve = v; } };
}
test("registered clipboard API defaults Allow with no permission UI; Ask has one scoped host dialog; Deny has no side effects", async (t) => {
const f = fixture(t);
assert.deepEqual(await f.call("ui.interact", { kind: "clipboard.read", title: "Read" }), { cancelled: false, value: "SYNTHETIC PRIVATE BODY" });
await f.call("ui.interact", { kind: "clipboard.write", title: "Copy", text: "SYNTHETIC PRIVATE BODY" });
assert.equal(f.decisions.length, 0); assert.equal(f.prompts.length, 0); assert.equal(f.writes.length, 1);
f.set("clipboard.read", "ask");
const result = f.call("ui.interact", { kind: "clipboard.read", title: "Read" });
await new Promise(setImmediate);
const prompt = f.prompts.find((p) => !p.closed);
assert.equal(prompt.phase, "preview"); assert.equal(prompt.permissionMode, "ask"); assert.equal(f.decisions.length, 0);
await f.interactions.respond(f.sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: "submit" });
assert.equal((await result).value, "SYNTHETIC PRIVATE BODY");
assert.equal(f.prompts.filter((p) => !p.closed).length, 1);
f.set("clipboard.write", "deny");
await assert.rejects(f.call("ui.interact", { kind: "clipboard.write", title: "Copy", text: "NEVER COPY" }), /disabled/);
assert.equal(f.writes.length, 1);
assert.equal(JSON.stringify(f.database.listSecurityAudit(manifest.id, 1000)).includes("SYNTHETIC PRIVATE BODY"), false);
});
test("pending workflow tokens and in-flight resource work cannot survive a Deny flip", async (t) => {
const f = fixture(t); f.set("clipboard.write", "ask");
const operation = f.call("ui.interact", { kind: "clipboard.write", title: "Copy", text: "NEVER COPY" });
const rejected = assert.rejects(operation, /changed|revoked/);
await new Promise(setImmediate);
const prompt = f.prompts.find((p) => !p.closed);
f.set("clipboard.write", "deny"); await rejected;
assert.equal(await f.interactions.respond(f.sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: "submit" }), false);
assert.deepEqual(f.writes, []);
let release; const gate = new Promise((resolve) => { release = resolve; });
f.registry.registerRequest("test.await", async (_params, context) => { await gate; await context.assertActive(); return "SECRET"; }, { authorization: { permission: "storage", resources: ["*"] } });
const work = f.registry.createRoutes(f.context).requestHandlers["test.await"]({}, {});
const stopped = assert.rejects(work, /changed/); await new Promise(setImmediate); f.set("storage", "deny"); release(); await stopped;
});
test("Deny beats saved grants; policy persists across restart and package changes while sources and plugins stay isolated", async (t) => {
const f = fixture(t);
const oldOwner = f.database.privateOwner(manifest.id);
f.set("storage", "deny");
f.database.upsertPermissionGrant({ pluginId: manifest.id, permission: "storage", resource: "*", resourceKind: "exact", declarationHash: "old-grant" });
const restarted = new PluginDatabase(f.filename); t.after(() => restarted.close());
assert.equal(restarted.getPermissionMode(manifest.id, "storage"), "deny");
const engine = new PluginPermissionEngine({ database: restarted }); t.after(() => engine.shutdown());
await assert.rejects(engine.authorize({ ...f.context, pluginVersion: "2.0.0", securityPrincipal: "unsigned-package:new-package-principal" }, { permission: "storage" }), /disabled/);
assert.equal(restarted.getPermissionMode("com.example.another", "storage"), "allow");
// Source replacement is an explicit host reset. The new source receives none
// of the old source's policies, grants or settings.
restarted.clearUninstalledSourceData(manifest.id, "local");
restarted.installVersion({ pluginId: manifest.id, version: "2.0.0", manifest, archiveSha256: "a".repeat(64), packageRelativePath: "test/package", source: OFFICIAL_SOURCE });
assert.equal(restarted.getPermissionMode(manifest.id, "storage"), "allow");
await assert.rejects(engine.authorize({ ...f.context, sourceOwner: oldOwner }, { permission: "storage" }), /source changed/);
});
test("registered network API enforces all modes and declared endpoint scope before fetch", async (t) => {
const f = fixture(t); let fetched = 0;
const broker = new PluginNetworkBroker({ permissionEngine: f.engine, fetch: async () => { fetched++; return new Response("ok"); } });
const registry = new PluginHostRpcRegistry();
registerSecurePluginCapabilities(registry, { permissionEngine: f.engine, quotaManager: { createMiddleware: () => (_context, next) => next() }, networkBroker: broker });
const call = (url) => registry.createRoutes(f.context).requestHandlers["network.request"]({ url }, {});
await call("https://example.com/path"); assert.equal(fetched, 1); assert.equal(f.decisions.length, 0);
f.set("network", "ask"); await call("https://example.com/path"); assert.equal(f.decisions.length, 1); assert.equal(fetched, 2);
f.approve(false); await assert.rejects(call("https://example.com/path"), /denied/); assert.equal(fetched, 2);
f.set("network", "deny"); await assert.rejects(call("https://example.com/path"), /disabled/); assert.equal(fetched, 2);
f.set("network", "allow"); await assert.rejects(call("https://other.example/path"), /did not declare/); assert.equal(fetched, 2);
assert.equal(registry.createRoutes(f.context).requestHandlers["unknown.method"], undefined);
});
test("one Ask decision covers model invocation and exact selected handles only for that operation", async (t) => {
const f = fixture(t);
const currentManifest = { ...manifest, permissions: { optional: ["ai.invoke", "context.selected.read"] } };
const context = { ...f.context, manifest: currentManifest };
const plugin = { id: manifest.id, manifest: currentManifest };
f.engine.setMode(plugin, "ai.invoke", "ask"); f.engine.setMode(plugin, "context.selected.read", "ask");
const selected = { permission: "context.selected.read", resources: ["selected:host-handle:v1"] };
await f.engine.authorize(context, { permission: "ai.invoke", relatedPermissions: [selected] });
await f.engine.authorize(context, selected);
assert.equal(f.decisions.length, 1);
assert.deepEqual(f.decisions[0].relatedPermissions, [{ permission: selected.permission, resources: selected.resources }]);
await f.engine.authorize({ ...context }, selected); assert.equal(f.decisions.length, 2, "receipt cannot cross RPC operations");
f.engine.setMode(plugin, "context.selected.read", "deny");
await assert.rejects(f.engine.authorize(context, selected), /disabled/);
});
test("Deny during an asynchronous authorization resolver invalidates the old call even after Allow", async (t) => {
const f = fixture(t); let release;
const pending = new Promise((resolve) => { release = resolve; });
let effects = 0;
f.registry.registerRequest("test.resolve", async () => { effects++; return "SECRET"; }, {
authorization: async () => { await pending; return { permission: "storage" }; },
});
const operation = f.registry.createRoutes(f.context).requestHandlers["test.resolve"]({}, {});
const rejected = assert.rejects(operation, /changed/);
await new Promise(setImmediate); f.set("storage", "deny"); f.set("storage", "allow"); release();
await rejected; assert.equal(effects, 0);
});
test("Ask does not prompt twice for a same-origin redirect within one registered network request", async (t) => {
const f = fixture(t); f.set("network", "ask"); let fetched = 0;
const broker = new PluginNetworkBroker({ permissionEngine: f.engine, fetch: async () => ++fetched === 1
? new Response(null, { status: 302, headers: { location: "/final" } }) : new Response("ok") });
const registry = new PluginHostRpcRegistry();
registerSecurePluginCapabilities(registry, { permissionEngine: f.engine, quotaManager: { createMiddleware: () => (_context, next) => next() }, networkBroker: broker });
const call = () => registry.createRoutes(f.context).requestHandlers["network.request"]({ url: "https://example.com/start" }, {});
await call(); assert.equal(fetched, 2); assert.equal(f.decisions.length, 1);
await call(); assert.equal(f.decisions.length, 2, "the decision does not leak to the next RPC operation");
});
+20 -1
View File
@@ -30,6 +30,9 @@ const CHANNELS = Object.freeze({
interactionResponse: "netcatty:plugins:interaction-response",
list: "netcatty:plugins:list",
permissions: "netcatty:plugins:permissions",
setPermissionMode: "netcatty:plugins:set-permission-mode",
permissionReady: "netcatty:plugins:permission-ready",
permissionResponse: "netcatty:plugins:permission-response",
revokePermissions: "netcatty:plugins:revoke-permissions",
resetSource: "netcatty:plugins:reset-source",
sourceBinding: "netcatty:plugins:source-binding",
@@ -710,6 +713,18 @@ function registerPluginBridge(ipcMain, options) {
await options.logService.ready;
return options.logService[action](payload);
});
handle(CHANNELS.permissionReady, async (_manager, payload, event) => options.permissionDecisionHost?.setReady(event.sender, payload?.ready));
handle(CHANNELS.permissionResponse, async (_manager, payload, event) => options.permissionDecisionHost?.respond(event.sender, payload));
handle(CHANNELS.setPermissionMode, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
const plugin = options.database?.getActivePlugin(pluginId);
if (!plugin || !options.permissionEngine) throw new Error("Plugin permissions are unavailable");
if (typeof payload?.policyRevision !== "string" || payload.policyRevision !== options.permissionEngine.getPolicyRevision(plugin)) {
throw new Error("Plugin permissions changed; refresh before editing");
}
options.permissionEngine.setMode(plugin, payload?.permission, payload?.mode);
return true;
});
handle(CHANNELS.permissions, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
@@ -736,8 +751,12 @@ function registerPluginBridge(ipcMain, options) {
event: entry.event, createdAt: entry.createdAt,
...(typeof entry.details?.permission === "string" ? { permission: entry.details.permission } : {}),
...(typeof entry.details?.scope === "string" ? { scope: entry.details.scope } : {}),
...(["allow", "ask", "deny"].includes(entry.details?.mode) ? { mode: entry.details.mode } : {}),
...(["completed", "cancelled", "preview-confirmed", "share-confirmed", "copy-confirmed", "draft-reviewed", "action-confirmed", "action-claimed"].includes(entry.details?.action)
? { action: entry.details.action } : {}),
}));
return { grants, audit };
return { grants, audit, ...(options.permissionEngine.describePolicies && plugin
? { policies: options.permissionEngine.describePolicies(plugin), policyRevision: options.permissionEngine.getPolicyRevision(plugin) } : {}) };
});
handle(CHANNELS.revokePermissions, async (activeManager, payload) => (
activeManager.revokePermissions(assertManagedPluginId(payload))
+24
View File
@@ -1735,3 +1735,27 @@ test("local log management rejects plugin/untrusted senders before touching the
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) await ipcMain.handlers.get(channel)({ trusted: true }, {});
assert.deepEqual(actions, ["status", "configure", "openFolder", "exportLogs", "clear"]);
});
test("policy edits require trusted UI and the exact source/version/policy revision", async () => {
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const ipcMain = createIpcMain();
let sourceOwner = "source-a", mode = "deny";
const plugin = { id: "com.example.policy", activeVersion: "1.0.0", manifest: { permissions: { optional: ["storage"] } } };
const engine = new PluginPermissionEngine({ database: { privateOwner: () => sourceOwner,
getPermissionMode: () => mode, setPermissionMode: (_id, _permission, value) => { mode = value; },
deleteAllPermissionGrants() {}, recordSecurityAudit() {},
} });
registerPluginBridge(ipcMain, { manager: { async initialize() {} }, isTrustedSender: (event) => event?.trusted === true,
permissionEngine: engine, database: { getActivePlugin: () => plugin } });
const edit = ipcMain.handlers.get(CHANNELS.setPermissionMode);
const payload = { pluginId: plugin.id, permission: "storage", mode: "allow", policyRevision: engine.getPolicyRevision(plugin) };
await assert.rejects(edit({}, payload), /Untrusted/);
for (const change of [() => { sourceOwner = "source-b"; }, () => { plugin.activeVersion = "2.0.0"; }, () => engine.revokeAll(plugin.id)]) {
const stale = { ...payload, policyRevision: engine.getPolicyRevision(plugin) }; change();
await assert.rejects(edit({ trusted: true }, stale), /refresh/);
assert.equal(mode, "deny");
}
assert.equal(await edit({ trusted: true }, { ...payload, policyRevision: engine.getPolicyRevision(plugin) }), true);
assert.equal(mode, "allow");
engine.shutdown();
});
@@ -516,7 +516,7 @@ class PluginTerminalDataPipelineService {
this.#assertAttachmentCurrent(session, direction, providerId, identity, options);
},
});
if (!["existing", "session", "application", "always"].includes(grant?.scope)) {
if (!["policy", "existing", "session", "application", "always"].includes(grant?.scope)) {
throw new PluginRpcError(
RPC_ERRORS.permissionDenied,
"Terminal interceptor streams require a session, application, or persistent permission grant",
@@ -76,9 +76,11 @@ async function fixture(t) {
const archive = path.join(root, 'fixture.ncpkg');
await buildPluginPackage(sourceRoot, archive);
await service.manager.install(archive, { enable: true });
service.database.setPermissionMode(manifest.id, "terminal.selection.read", "ask");
service.database.setPermissionMode(manifest.id, "terminal.input.propose", "ask");
};
return { root, service, sender, window, manager, runtime, select, install, writes, prompts, decisions, responses,
mode(value) { mode = value; }, selection(value) { selection = value; }, allow(value) { allow = value; },
mode(value) { mode = value; }, selection(value) { selection = value; }, allow(value) { allow = value; service.permissionEngine.setMode({ id, manifest }, "terminal.selection.read", value ? "ask" : "deny"); },
async startB() { nextSession = 'terminal-b'; await manager.request('netcatty:local:start', {}, { webContentsId: 7 }); select('terminal-b', 'renderer-b'); },
};
}
@@ -111,7 +113,7 @@ test('installed entrypoint keeps consent, exact snapshots, separate references a
await f.install(source, manifest);
const run = (name) => f.service.contributionService.executeCommand(`${id}.${name}`);
f.allow(false);
await assert.rejects(run('share'), /denied/);
await assert.rejects(run('share'), /disabled/);
assert.equal(f.prompts.filter((p) => !p.closed).length, 0);
f.allow(true); f.mode('cancel');
assert.deepEqual(await run('share'), { cancelled: true });
@@ -137,11 +139,10 @@ test('installed entrypoint keeps consent, exact snapshots, separate references a
assert.equal(f.prompts.filter((p) => p.request?.kind === 'terminal.input.propose').at(-1).terminalSessionId, 'terminal-b');
await assert.rejects(run('spoof'), /unavailable/);
assert.deepEqual(f.writes, []);
const selectionDecision = f.decisions.find((d) => d.permission === 'terminal.selection.read');
const draftDecision = f.decisions.find((d) => d.permission === 'terminal.input.propose');
assert.deepEqual(selectionDecision.allowedScopes, ['once']);
assert.deepEqual(draftDecision.allowedScopes, ['once']);
assert.match(selectionDecision.resources[0], /^terminal-session:terminal-a:window:7:/);
assert.equal(f.decisions.length, 0, 'Ask is integrated into host sharing and draft UI');
assert.ok(f.prompts.some((p) => p.permissionMode === 'ask'));
assert.match(f.prompts.find((p) => p.request?.kind === 'terminal.selection.read').terminalSessionId, /terminal-a/);
f.service.interactionService.rates.clear();
f.service.permissionEngine.revokeApplication(id, 'terminal.input.propose');
await assert.rejects(run('draftLast'), /unavailable/);
const audit = JSON.stringify(f.service.database.listSecurityAudit?.(id) ?? []);
@@ -224,3 +225,23 @@ test('changing the selected renderer cancels an installed preview and rejects la
assert.equal(f.service.interactionService.terminalSelection.handles.size, 0);
assert.deepEqual(f.writes, []);
});
test('installed Allow shares the host-selected snapshot without confirmation; Deny releases no text or draft', { timeout: 15_000 }, async (t) => {
const f = await fixture(t); await f.install(source, manifest);
const plugin = f.service.database.getActivePlugin(id);
f.service.permissionEngine.setMode(plugin, 'terminal.selection.read', 'allow');
f.service.permissionEngine.setMode(plugin, 'terminal.input.propose', 'allow');
const run = (name) => f.service.contributionService.executeCommand(`${id}.${name}`);
const shared = await run('share'); assert.equal(shared.text, 'synthetic owned selection\nsecond line');
assert.equal(f.prompts.filter((p) => p.phase === 'preview').length, 0, 'no sharing confirmation in Allow');
assert.equal(f.decisions.length, 0);
assert.equal(await run('draftLast'), true, 'editable draft remains a real workflow');
assert.deepEqual(f.writes, []);
f.service.permissionEngine.setMode(plugin, 'terminal.selection.read', 'deny');
const count = f.prompts.length;
await assert.rejects(run('share'), /disabled/);
assert.equal(f.prompts.length, count);
assert.equal(f.service.interactionService.terminalSelection.handles.size, 0);
await assert.rejects(run('draftLast'), /unavailable/);
assert.deepEqual(f.writes, []);
});
+8
View File
@@ -123,6 +123,14 @@ function createPreloadApi(ctx) {
return () => ipcRenderer.removeListener("netcatty:plugins:host-interaction", listener);
},
listPlugins: () => ipcRenderer.invoke("netcatty:plugins:list"),
setPluginPermissionMode: (pluginId, permission, mode, policyRevision) => ipcRenderer.invoke("netcatty:plugins:set-permission-mode", { pluginId, permission, mode, policyRevision }),
setPluginPermissionReady: (ready) => ipcRenderer.invoke("netcatty:plugins:permission-ready", { ready }),
respondPluginPermission: (response) => ipcRenderer.invoke("netcatty:plugins:permission-response", response),
onPluginPermission: (callback) => {
const listener = (_event, prompt) => callback(prompt);
ipcRenderer.on("netcatty:plugins:permission-prompt", listener);
return () => ipcRenderer.removeListener("netcatty:plugins:permission-prompt", listener);
},
getPluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:permissions", { pluginId }),
revokePluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:revoke-permissions", { pluginId }),
installPluginPackage: (archivePath, options) => ipcRenderer.invoke("netcatty:plugins:install", {
Vendored
+11 -1
View File
@@ -355,13 +355,19 @@ declare global {
artifact?: { url: string; sha256: string };
}
type PluginPermissionMode = 'allow' | 'ask' | 'deny';
type NetcattyPluginPermissionPrompt = import('@netcatty/plugin-contract').PermissionRequest & {
actionToken: string; resources: string[];
};
interface NetcattyPluginPermissionSnapshot {
policyRevision?: string;
policies?: Array<{ permission: string; resources: string[]; mode: PluginPermissionMode }>;
grants: Array<{
permission: string; resource: string; resourceKind: string;
scope: 'always' | 'application' | 'session'; sessionId: string | null; grantedAt: number;
matchesCurrentActivation: boolean;
}>;
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string }>;
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string; mode?: PluginPermissionMode; action?: string }>;
}
interface NetcattyPluginSource {
@@ -480,6 +486,10 @@ declare global {
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<import("@netcatty/plugin-contract").HostInteractionReceipt>;
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
setPluginPermissionMode?(pluginId: string, permission: string, mode: PluginPermissionMode, policyRevision: string): Promise<boolean>;
setPluginPermissionReady?(ready: boolean): Promise<boolean>;
respondPluginPermission?(response: { requestId: string; actionToken: string; decision: 'allow' | 'deny' | 'cancel' }): Promise<boolean>;
onPluginPermission?(callback: (event: NetcattyPluginPermissionPrompt | { requestId: string; closed: true }) => void): () => void;
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
revokePluginPermissions?(pluginId: string): Promise<NetcattyInstalledPlugin>;
installPluginPackage?(archivePath: string, options?: { enable?: boolean }): Promise<NetcattyInstalledPlugin>;
@@ -1042,6 +1042,7 @@
"request"
],
"properties": {
"permissionMode": { "enum": ["allow", "ask", "deny"] },
"requestId": {
"type": "string",
"maxLength": 128
@@ -3737,6 +3738,17 @@
"maxItems": 128,
"items": { "$ref": "#/$defs/PermissionResourceKind" }
},
"relatedPermissions": {
"type": "array", "maxItems": 5,
"items": {
"type": "object", "required": ["permission", "resources"],
"properties": {
"permission": { "$ref": "#/$defs/PluginPermission" },
"resources": { "type": "array", "minItems": 1, "maxItems": 128, "uniqueItems": true, "items": { "$ref": "#/$defs/PermissionResource" } }
},
"additionalProperties": false
}
},
"reason": { "type": "string", "minLength": 1, "maxLength": 1024 },
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
"sessionId": { "type": "string", "minLength": 1, "maxLength": 128 },
@@ -223,6 +223,7 @@ export type HostInteractionFeature = "host.interactions";
export type HostInteractionLimits = {"textBytes":4096,"requestBytes":65536,"items":100,"pendingPerRuntime":1,"pendingTotal":8,"requestsPerMinute":10,"timeoutMs":60000};
export type HostInteractionPrompt = {
permissionMode?: "allow" | "ask" | "deny";
requestId: string;
actionToken: string;
pluginId: PluginId;
@@ -520,6 +521,10 @@ export type PermissionRequest = {
permission: PluginPermission;
resources?: Array<PermissionResource>;
resourceKinds?: Array<PermissionResourceKind>;
relatedPermissions?: Array<{
permission: PluginPermission;
resources: Array<PermissionResource>;
}>;
reason: string;
operationId?: string;
sessionId?: string;