Files

254 lines
8.7 KiB
JavaScript
Raw Permalink Normal View History

/** @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}`,
};
}