14a00774d9
- 新增 web 能力并挂载 platform/web(web_search/fetch_url) - 实时查询强制 web skill,router fallback 与 await session Finish - Session Broker 覆盖率/指标、stream replay 与相关单测/E2E 脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
254 lines
8.7 KiB
JavaScript
254 lines
8.7 KiB
JavaScript
/** @typedef {'auto' | 'approve' | 'smart_approve' | 'chat'} GooseMode */
|
||
/** @typedef {'readwrite' | 'readonly'} WorkspaceAccess */
|
||
/** @typedef {'allow' | 'deny'} NetworkEgress */
|
||
|
||
export const POLICY_CATALOG = [
|
||
{
|
||
key: 'goose_mode',
|
||
label: 'TKMind 模式',
|
||
description:
|
||
'H5 终端用户不在聊天里点「允许/拒绝」;可用工具由「能力权限」决定。chat 不调用工具,auto 在授权范围内自动执行。',
|
||
type: 'select',
|
||
options: [
|
||
{ value: 'chat', label: '仅聊天 (chat)' },
|
||
{ value: 'auto', label: '自动执行 (auto)' },
|
||
],
|
||
defaultValue: 'chat',
|
||
category: 'goose',
|
||
risk: 'high',
|
||
},
|
||
{
|
||
key: 'workspace_access',
|
||
label: '工作区访问',
|
||
description: 'readonly 时禁止 shell 与文件写入,仅允许浏览与分析',
|
||
type: 'select',
|
||
options: [
|
||
{ value: 'readwrite', label: '读写' },
|
||
{ value: 'readonly', label: '只读' },
|
||
],
|
||
defaultValue: 'readwrite',
|
||
category: 'workspace',
|
||
risk: 'medium',
|
||
},
|
||
{
|
||
key: 'network_egress',
|
||
label: '网络出站',
|
||
description: 'deny 时禁用 shell、电脑控制、沙箱脚本等可能访问网络的扩展',
|
||
type: 'select',
|
||
options: [
|
||
{ value: 'deny', label: '禁止(推荐)' },
|
||
{ value: 'allow', label: '允许(需同时开启对应能力)' },
|
||
],
|
||
defaultValue: 'deny',
|
||
category: 'network',
|
||
risk: 'high',
|
||
},
|
||
{
|
||
key: 'api_lockdown',
|
||
label: 'API 代理锁定',
|
||
description: '开启后仅允许聊天相关 API,拦截改模式、加扩展、写配置等危险请求',
|
||
type: 'boolean',
|
||
defaultValue: true,
|
||
category: 'proxy',
|
||
risk: 'medium',
|
||
},
|
||
{
|
||
key: 'code_delegate_executor',
|
||
label: '代码委托执行器',
|
||
description:
|
||
'控制 Goose 在多文件编码、修复与重构任务中优先委托给谁。auto 由 Goose 结合当前可用扩展自行判断;aider / openhands 则优先使用指定执行器。',
|
||
type: 'select',
|
||
options: [
|
||
{ value: 'auto', label: '自动选择 (auto)' },
|
||
{ value: 'aider', label: '优先 Aider' },
|
||
{ value: 'openhands', label: '优先 OpenHands' },
|
||
],
|
||
defaultValue: 'auto',
|
||
category: 'routing',
|
||
risk: 'medium',
|
||
},
|
||
{
|
||
key: 'code_task_routing',
|
||
label: '代码任务路由策略',
|
||
description:
|
||
'定义代码任务如何在 Aider 与 OpenHands 之间分流。balanced 由 Goose 按任务复杂度判断;split 建议小改动走 Aider、复杂任务走 OpenHands;force_* 则强制优先单一路径。',
|
||
type: 'select',
|
||
options: [
|
||
{ value: 'balanced', label: '平衡路由 (balanced)' },
|
||
{ value: 'split', label: '小改动 Aider,复杂任务 OpenHands' },
|
||
{ value: 'force_aider', label: '尽量统一走 Aider' },
|
||
{ value: 'force_openhands', label: '尽量统一走 OpenHands' },
|
||
],
|
||
defaultValue: 'balanced',
|
||
category: 'routing',
|
||
risk: 'medium',
|
||
},
|
||
];
|
||
|
||
export const DEFAULT_USER_POLICIES = Object.fromEntries(
|
||
POLICY_CATALOG.map((item) => [item.key, item.defaultValue]),
|
||
);
|
||
|
||
const POLICY_KEYS = new Set(POLICY_CATALOG.map((item) => item.key));
|
||
const GOOSE_MODES = new Set(['auto', 'approve', 'smart_approve', 'chat']);
|
||
const WORKSPACE_ACCESS = new Set(['readwrite', 'readonly']);
|
||
const NETWORK_EGRESS = new Set(['allow', 'deny']);
|
||
const CODE_DELEGATE_EXECUTORS = new Set(['auto', 'aider', 'openhands']);
|
||
const CODE_TASK_ROUTINGS = new Set(['balanced', 'split', 'force_aider', 'force_openhands']);
|
||
|
||
export function policyKeys() {
|
||
return [...POLICY_KEYS];
|
||
}
|
||
|
||
export function isValidPolicyKey(key) {
|
||
return POLICY_KEYS.has(key);
|
||
}
|
||
|
||
export function normalizePolicyPatch(patch) {
|
||
const normalized = {};
|
||
for (const [key, raw] of Object.entries(patch ?? {})) {
|
||
if (!isValidPolicyKey(key)) continue;
|
||
const def = POLICY_CATALOG.find((item) => item.key === key);
|
||
if (!def) continue;
|
||
if (def.type === 'boolean') {
|
||
normalized[key] = raw === true || raw === 'true' || raw === 1 || raw === '1';
|
||
continue;
|
||
}
|
||
const value = String(raw ?? '').trim();
|
||
if (def.key === 'goose_mode' && GOOSE_MODES.has(value)) normalized[key] = value;
|
||
if (def.key === 'workspace_access' && WORKSPACE_ACCESS.has(value)) normalized[key] = value;
|
||
if (def.key === 'network_egress' && NETWORK_EGRESS.has(value)) normalized[key] = value;
|
||
if (def.key === 'code_delegate_executor' && CODE_DELEGATE_EXECUTORS.has(value)) {
|
||
normalized[key] = value;
|
||
}
|
||
if (def.key === 'code_task_routing' && CODE_TASK_ROUTINGS.has(value)) {
|
||
normalized[key] = value;
|
||
}
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function resolvePolicies(rolePolicies, userOverrides) {
|
||
const resolved = { ...DEFAULT_USER_POLICIES };
|
||
for (const [key, value] of Object.entries(rolePolicies ?? {})) {
|
||
if (isValidPolicyKey(key)) resolved[key] = value;
|
||
}
|
||
for (const [key, value] of Object.entries(userOverrides ?? {})) {
|
||
if (isValidPolicyKey(key)) resolved[key] = value;
|
||
}
|
||
if (resolved.goose_mode === 'approve' || resolved.goose_mode === 'smart_approve') {
|
||
resolved.goose_mode = 'auto';
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
function hasExecutableTools(capabilities) {
|
||
return Boolean(
|
||
capabilities?.static_publish
|
||
|| capabilities?.shell
|
||
|| capabilities?.filesystem
|
||
|| capabilities?.code_browse
|
||
|| capabilities?.subagent
|
||
|| capabilities?.code_sandbox
|
||
|| capabilities?.computer
|
||
|| capabilities?.charts
|
||
|| capabilities?.aider
|
||
|| capabilities?.openhands
|
||
|| capabilities?.apps
|
||
|| capabilities?.todo
|
||
|| capabilities?.skills
|
||
|| capabilities?.chat_recall,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Goose mode for H5 agent sessions. Tool allow/deny is enforced via capability grants,
|
||
* not end-user confirmation dialogs.
|
||
*/
|
||
export function resolveAgentGooseMode(capabilities, policies) {
|
||
const requested = policies?.goose_mode ?? DEFAULT_USER_POLICIES.goose_mode;
|
||
if (!hasExecutableTools(capabilities)) {
|
||
return 'chat';
|
||
}
|
||
if (requested === 'chat' || requested === 'approve' || requested === 'smart_approve') {
|
||
return 'auto';
|
||
}
|
||
return 'auto';
|
||
}
|
||
|
||
/**
|
||
* Apply security policies on top of capability flags before building extension overrides.
|
||
*/
|
||
export function applyPoliciesToCapabilities(capabilities, policies) {
|
||
const effective = { ...capabilities };
|
||
if (policies.workspace_access === 'readonly') {
|
||
effective.shell = false;
|
||
effective.filesystem = false;
|
||
effective.static_publish = false;
|
||
effective.aider = false;
|
||
effective.openhands = false;
|
||
effective.code_sandbox = false;
|
||
effective.image_read = false;
|
||
}
|
||
if (policies.network_egress === 'deny') {
|
||
// static_publish 用户的 shell 仅能在本工作区内执行本地命令,不依赖外网出站
|
||
if (!effective.static_publish) {
|
||
effective.shell = false;
|
||
}
|
||
effective.computer = false;
|
||
effective.web = false;
|
||
effective.code_sandbox = false;
|
||
}
|
||
return effective;
|
||
}
|
||
|
||
const USER_API_ALLOWLIST = [
|
||
{ method: 'GET', pattern: /^\/status$/ },
|
||
{ method: 'POST', pattern: /^\/agent\/start$/ },
|
||
{ method: 'POST', pattern: /^\/agent\/resume$/ },
|
||
{ method: 'POST', pattern: /^\/agent\/runs$/ },
|
||
{ method: 'GET', pattern: /^\/agent\/runs\/[^/]+$/ },
|
||
{ method: 'GET', pattern: /^\/agent\/runs\/[^/]+\/events$/ },
|
||
{ method: 'GET', pattern: /^\/sessions$/ },
|
||
{ method: 'GET', pattern: /^\/sessions\/[^/]+$/ },
|
||
{ method: 'DELETE', pattern: /^\/sessions\/[^/]+$/ },
|
||
{ method: 'GET', pattern: /^\/sessions\/[^/]+\/events$/ },
|
||
{ method: 'POST', pattern: /^\/sessions\/[^/]+\/reply$/ },
|
||
{ method: 'POST', pattern: /^\/sessions\/[^/]+\/cancel$/ },
|
||
{ method: 'POST', pattern: /^\/action-required\/tool-confirmation$/ },
|
||
{ method: 'POST', pattern: /^\/agent\/update_provider$/ },
|
||
{ method: 'POST', pattern: /^\/config\/read$/ },
|
||
{ method: 'POST', pattern: /^\/agent\/harness_bootstrap$/ },
|
||
{ method: 'POST', pattern: /^\/agent\/harness_remember$/ },
|
||
];
|
||
|
||
export function isNativeH5ApiPath(pathname) {
|
||
const path = String(pathname ?? '');
|
||
return path.startsWith('/mindspace/') || path.startsWith('/plaza/');
|
||
}
|
||
|
||
/**
|
||
* @returns {{ allowed: boolean, reason?: string }}
|
||
*/
|
||
export function evaluateProxyRequest(method, pathname, policies, { unrestricted = false } = {}) {
|
||
if (unrestricted) return { allowed: true };
|
||
if (!policies.api_lockdown) return { allowed: true };
|
||
if (isNativeH5ApiPath(pathname)) {
|
||
return {
|
||
allowed: false,
|
||
reason: `H5 本地接口不应走 Agent 代理:${method.toUpperCase()} ${pathname}`,
|
||
};
|
||
}
|
||
|
||
const upper = method.toUpperCase();
|
||
const allowed = USER_API_ALLOWLIST.some(
|
||
(rule) => rule.method === upper && rule.pattern.test(pathname),
|
||
);
|
||
if (allowed) return { allowed: true };
|
||
|
||
return {
|
||
allowed: false,
|
||
reason: `策略已锁定 API:${upper} ${pathname}`,
|
||
};
|
||
}
|