feat(agents): Phase3.1 工具/MCP权限沙箱(tool_policy) 白名单+预算熔断+每轮上限+后台管理UI [v2026.07.19.6]
CI / gate (push) Has been cancelled
CI / gate (push) Has been cancelled
This commit is contained in:
1 parent
2969544d54
commit
0600c37b9e
8 files changed
+5557
-5300
No files matched your search
File diff suppressed because it is too large.
Load diff
+2090
-2011
File diff suppressed because it is too large.
Load diff
+1593
-1592
File diff suppressed because it is too large.
Load diff
+1137
-1136
File diff suppressed because it is too large.
Load diff
@@ -887,6 +887,10 @@ export const adminAPI = {
|
|||||||
|
|
||||||
getAdminAgentReviews: (id: string) => get<any>(`/admin/agents/${id}/reviews`),
|
getAdminAgentReviews: (id: string) => get<any>(`/admin/agents/${id}/reviews`),
|
||||||
|
|
||||||
|
// Phase 3.1: 工具/MCP 权限沙箱 (tool_policy)
|
||||||
|
getAgentToolPolicy: (id: string) => get<any>(`/admin/agents/${id}/tool-policy`),
|
||||||
|
setAgentToolPolicy: (id: string, toolPolicy: any) => put<any>(`/admin/agents/${id}/tool-policy`, { tool_policy: toolPolicy }),
|
||||||
|
|
||||||
getAdminAvailableSkills: (params?: any) => {
|
getAdminAvailableSkills: (params?: any) => {
|
||||||
|
|
||||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ const Agents: React.FC = () => {
|
|||||||
// Available skills for linking
|
// Available skills for linking
|
||||||
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
|
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
|
||||||
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
|
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
|
||||||
|
// Available MCP servers for tool_policy sandbox allowlist
|
||||||
|
const [availableMcps, setAvailableMcps] = useState<any[]>([]);
|
||||||
|
// Tool policy (sandbox) — null means disabled / no policy
|
||||||
|
const [toolPolicy, setToolPolicy] = useState<any>(null);
|
||||||
|
|
||||||
// Toast
|
// Toast
|
||||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||||
@@ -144,14 +148,17 @@ const Agents: React.FC = () => {
|
|||||||
// Load available skills & models
|
// Load available skills & models
|
||||||
const loadAvailable = async () => {
|
const loadAvailable = async () => {
|
||||||
try {
|
try {
|
||||||
const [skillsResult, modelsResult] = await Promise.all([
|
const [skillsResult, modelsResult, mcpsResult] = await Promise.all([
|
||||||
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
|
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
|
||||||
adminAPI.getAdminAvailableModels() as any,
|
adminAPI.getAdminAvailableModels() as any,
|
||||||
|
adminAPI.getAdminMcpServers({ page_size: 50 }) as any,
|
||||||
]);
|
]);
|
||||||
const skills = (skillsResult.data || skillsResult).items || [];
|
const skills = (skillsResult.data || skillsResult).items || [];
|
||||||
const models = (modelsResult.data || modelsResult) || [];
|
const models = (modelsResult.data || modelsResult) || [];
|
||||||
|
const mcps = (mcpsResult.data || mcpsResult).items || [];
|
||||||
setAvailableSkills(skills);
|
setAvailableSkills(skills);
|
||||||
setAvailableModels(models);
|
setAvailableModels(models);
|
||||||
|
setAvailableMcps(mcps);
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,6 +194,7 @@ const Agents: React.FC = () => {
|
|||||||
memory_enabled: false,
|
memory_enabled: false,
|
||||||
reasoning_enabled: false,
|
reasoning_enabled: false,
|
||||||
});
|
});
|
||||||
|
setToolPolicy(null);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,6 +202,7 @@ const Agents: React.FC = () => {
|
|||||||
const openEdit = async (agent: Agent) => {
|
const openEdit = async (agent: Agent) => {
|
||||||
setEditingAgent(agent);
|
setEditingAgent(agent);
|
||||||
await loadAvailable();
|
await loadAvailable();
|
||||||
|
await openEditLoadPolicy(agent.id);
|
||||||
|
|
||||||
const cfg = agent.config || {};
|
const cfg = agent.config || {};
|
||||||
setForm({
|
setForm({
|
||||||
@@ -213,7 +222,15 @@ const Agents: React.FC = () => {
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open detail
|
// Fetch existing tool_policy (sandbox) for this agent
|
||||||
|
const openEditLoadPolicy = async (agentId: string) => {
|
||||||
|
try {
|
||||||
|
const tpRes = await adminAPI.getAgentToolPolicy(agentId) as any;
|
||||||
|
setToolPolicy(tpRes?.data?.tool_policy || null);
|
||||||
|
} catch {
|
||||||
|
setToolPolicy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
const openDetail = async (agent: Agent) => {
|
const openDetail = async (agent: Agent) => {
|
||||||
setDetailAgent(agent);
|
setDetailAgent(agent);
|
||||||
setShowDetail(true);
|
setShowDetail(true);
|
||||||
@@ -246,7 +263,13 @@ const Agents: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
if (editingAgent) {
|
if (editingAgent) {
|
||||||
await adminAPI.updateAdminAgent(editingAgent.id, payload);
|
await adminAPI.updateAdminAgent(editingAgent.id, payload);
|
||||||
showToast('Agent 已更新');
|
// Phase 3.1: persist tool_policy sandbox (null clears it)
|
||||||
|
try {
|
||||||
|
await adminAPI.setAgentToolPolicy(editingAgent.id, toolPolicy);
|
||||||
|
showToast('Agent 已更新');
|
||||||
|
} catch (pe: any) {
|
||||||
|
showToast(`Agent 已更新,但策略保存失败: ${pe.message || pe}`, 'error');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await adminAPI.createAdminAgent(payload);
|
await adminAPI.createAdminAgent(payload);
|
||||||
showToast('Agent 创建成功');
|
showToast('Agent 创建成功');
|
||||||
@@ -612,6 +635,87 @@ const Agents: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{editingAgent && (
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 12 }}>🛡️ 工具权限沙箱 (tool_policy)</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 12 }}>
|
||||||
|
<input type="checkbox" checked={toolPolicy !== null} onChange={e => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setToolPolicy({ allowed_tools: { skill_ids: [], mcp_ids: [] }, max_calls_per_conversation: null, per_round_cap: null, fallback_message: '' });
|
||||||
|
} else {
|
||||||
|
setToolPolicy(null);
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
启用工具/MCP 权限沙箱(限制该 Agent 可调用的工具与调用频次)
|
||||||
|
</label>
|
||||||
|
{toolPolicy && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">允许调用的技能(不选 = 禁止调用任何技能)</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
|
||||||
|
{availableSkills.length === 0 ? (
|
||||||
|
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无技能</span>
|
||||||
|
) : availableSkills.map((skill: any) => {
|
||||||
|
const checked = (toolPolicy.allowed_tools?.skill_ids || []).includes(skill.id);
|
||||||
|
return (
|
||||||
|
<label key={skill.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 12, fontSize: 12, cursor: 'pointer', background: checked ? '#EEF2FF' : '#F9FAFB', border: `1px solid ${checked ? '#6366F1' : '#E5E7EB'}`, color: checked ? '#6366F1' : '#374151' }}>
|
||||||
|
<input type="checkbox" style={{ display: 'none' }} checked={checked} onChange={() => {
|
||||||
|
const cur = toolPolicy.allowed_tools?.skill_ids || [];
|
||||||
|
const next = checked ? cur.filter((x: string) => x !== skill.id) : [...cur, skill.id];
|
||||||
|
setToolPolicy({ ...toolPolicy, allowed_tools: { ...(toolPolicy.allowed_tools || {}), skill_ids: next } });
|
||||||
|
}} />
|
||||||
|
{skill.skill_name}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">允许调用的 MCP 服务(不选 = 禁止调用任何 MCP)</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
|
||||||
|
{availableMcps.length === 0 ? (
|
||||||
|
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无 MCP 服务</span>
|
||||||
|
) : availableMcps.map((m: any) => {
|
||||||
|
const checked = (toolPolicy.allowed_tools?.mcp_ids || []).includes(m.id);
|
||||||
|
return (
|
||||||
|
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 12, fontSize: 12, cursor: 'pointer', background: checked ? '#EEF2FF' : '#F9FAFB', border: `1px solid ${checked ? '#6366F1' : '#E5E7EB'}`, color: checked ? '#6366F1' : '#374151' }}>
|
||||||
|
<input type="checkbox" style={{ display: 'none' }} checked={checked} onChange={() => {
|
||||||
|
const cur = toolPolicy.allowed_tools?.mcp_ids || [];
|
||||||
|
const next = checked ? cur.filter((x: string) => x !== m.id) : [...cur, m.id];
|
||||||
|
setToolPolicy({ ...toolPolicy, allowed_tools: { ...(toolPolicy.allowed_tools || {}), mcp_ids: next } });
|
||||||
|
}} />
|
||||||
|
{m.name}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">单对话最大调用次数(留空=不限制)</label>
|
||||||
|
<input className="admin-form-input" type="number" min="0"
|
||||||
|
value={toolPolicy.max_calls_per_conversation ?? ''}
|
||||||
|
onChange={e => setToolPolicy({ ...toolPolicy, max_calls_per_conversation: e.target.value === '' ? null : parseInt(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">单轮最大调用次数(留空=不限制)</label>
|
||||||
|
<input className="admin-form-input" type="number" min="0"
|
||||||
|
value={toolPolicy.per_round_cap ?? ''}
|
||||||
|
onChange={e => setToolPolicy({ ...toolPolicy, per_round_cap: e.target.value === '' ? null : parseInt(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">额度耗尽提示语(可选)</label>
|
||||||
|
<textarea className="admin-form-input" rows={2}
|
||||||
|
value={toolPolicy.fallback_message || ''}
|
||||||
|
onChange={e => setToolPolicy({ ...toolPolicy, fallback_message: e.target.value })}
|
||||||
|
placeholder="例如:该智能体已达到工具调用上限,请开启新对话或调整智能体设置。" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
|
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Phase 3.1: MarketApp 增加 tool_policy(Agent 工具/MCP 权限沙箱策略)
|
||||||
|
-- 幂等:仅当列不存在时才 ALTER
|
||||||
|
SET @db = 'aiflow';
|
||||||
|
SET @tbl = 'market_apps';
|
||||||
|
SET @col = 'tool_policy';
|
||||||
|
SET @exists = (
|
||||||
|
SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema = @db AND table_name = @tbl AND column_name = @col
|
||||||
|
);
|
||||||
|
SET @sql = IF(@exists = 0,
|
||||||
|
'ALTER TABLE market_apps ADD COLUMN tool_policy JSON NULL',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
Reference in new issue
Block a user