Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b34ba78b8 | ||
|
|
0600c37b9e |
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+2090
-2011
File diff suppressed because it is too large.
Load diff
+1594
-1592
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
from app.core.credential import encrypt_secret
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, desc, asc, func
|
||||
@@ -557,7 +558,7 @@ def upload_app(
|
||||
is_member_free=req.is_member_free if req.is_member_free is not None else False,
|
||||
join_promotion=req.join_promotion if req.join_promotion is not None else False,
|
||||
agent_endpoint=req.agent_endpoint,
|
||||
agent_api_key=req.api_key,
|
||||
agent_api_key=encrypt_secret(req.api_key),
|
||||
status="published", # 用户创建的应用直接发布
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Phase 3.2 - Agent 凭据加解密 (Fernet 对称加密)。
|
||||
|
||||
主密钥获取策略 (防竞态):
|
||||
- 优先环境变量 AGENT_MASTER_KEY。
|
||||
- 否则读写 /app/secrets/agent_master_key:
|
||||
* 部署阶段由 patch 显式生成并落盘 (原子写), 容器重启/提交后稳定存在。
|
||||
* 运行时只读, 文件存在即读取, 绝不重新生成 —— 避免多进程/多请求并发
|
||||
重建密钥导致新旧密文无法互解。
|
||||
* 仅当文件确实不存在时, 才在文件锁保护下生成 (双重检查, 原子替换)。
|
||||
- 存储格式: 密文以 "enc:" 前缀标识; 非 "enc:" 视为旧明文 (向后兼容, 解密原样返回)。
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import fcntl
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MASTER_KEY_FILE = "/app/secrets/agent_master_key"
|
||||
_fernet_instance = None
|
||||
|
||||
|
||||
def _generate_and_persist_key() -> bytes:
|
||||
key = Fernet.generate_key()
|
||||
os.makedirs(os.path.dirname(_MASTER_KEY_FILE), exist_ok=True)
|
||||
tmp = _MASTER_KEY_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(key.decode())
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(tmp, _MASTER_KEY_FILE) # 原子替换, 避免读到半截文件
|
||||
return key
|
||||
|
||||
|
||||
def get_master_key() -> bytes:
|
||||
env_key = os.environ.get("AGENT_MASTER_KEY")
|
||||
if env_key:
|
||||
return env_key.encode() if isinstance(env_key, str) else env_key
|
||||
# 运行时只读: 文件已存在则直接读取 (部署阶段已生成)
|
||||
if os.path.exists(_MASTER_KEY_FILE):
|
||||
try:
|
||||
with open(_MASTER_KEY_FILE, "r") as f:
|
||||
k = f.read().strip()
|
||||
if k:
|
||||
return k.encode()
|
||||
except Exception as e:
|
||||
logger.warning("[credential] 读取主密钥失败: %s" % e)
|
||||
# 文件缺失: 加锁串行生成, 防多进程并发重建
|
||||
lockf = open(_MASTER_KEY_FILE + ".lock", "w")
|
||||
try:
|
||||
fcntl.flock(lockf, fcntl.LOCK_EX)
|
||||
if os.path.exists(_MASTER_KEY_FILE): # 双重检查
|
||||
with open(_MASTER_KEY_FILE, "r") as f:
|
||||
k = f.read().strip()
|
||||
if k:
|
||||
return k.encode()
|
||||
return _generate_and_persist_key()
|
||||
finally:
|
||||
fcntl.flock(lockf, fcntl.LOCK_UN)
|
||||
try:
|
||||
lockf.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
global _fernet_instance
|
||||
if _fernet_instance is None:
|
||||
_fernet_instance = Fernet(get_master_key())
|
||||
return _fernet_instance
|
||||
|
||||
|
||||
def encrypt_secret(plain):
|
||||
if plain is None or plain == "":
|
||||
return None
|
||||
if isinstance(plain, str) and plain.startswith("enc:"):
|
||||
return plain # 已是密文, 幂等
|
||||
try:
|
||||
return "enc:" + _fernet().encrypt(plain.encode()).decode()
|
||||
except Exception as e:
|
||||
logger.error("[credential] 加密失败: %s" % e)
|
||||
return None
|
||||
|
||||
|
||||
def decrypt_secret(cipher):
|
||||
if cipher is None or cipher == "":
|
||||
return None
|
||||
if isinstance(cipher, str) and cipher.startswith("enc:"):
|
||||
try:
|
||||
return _fernet().decrypt(cipher[4:].encode()).decode()
|
||||
except Exception as e:
|
||||
logger.error("[credential] 解密失败(密钥不匹配或数据损坏): %s" % e)
|
||||
return None
|
||||
return cipher # 旧明文, 向后兼容
|
||||
|
||||
|
||||
def mask_secret(plain):
|
||||
if not plain:
|
||||
return ""
|
||||
return ("*" * 8) + plain[-4:]
|
||||
+1138
-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`),
|
||||
|
||||
// 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) => {
|
||||
|
||||
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
|
||||
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
|
||||
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
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
@@ -144,14 +148,17 @@ const Agents: React.FC = () => {
|
||||
// Load available skills & models
|
||||
const loadAvailable = async () => {
|
||||
try {
|
||||
const [skillsResult, modelsResult] = await Promise.all([
|
||||
const [skillsResult, modelsResult, mcpsResult] = await Promise.all([
|
||||
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
|
||||
adminAPI.getAdminAvailableModels() as any,
|
||||
adminAPI.getAdminMcpServers({ page_size: 50 }) as any,
|
||||
]);
|
||||
const skills = (skillsResult.data || skillsResult).items || [];
|
||||
const models = (modelsResult.data || modelsResult) || [];
|
||||
const mcps = (mcpsResult.data || mcpsResult).items || [];
|
||||
setAvailableSkills(skills);
|
||||
setAvailableModels(models);
|
||||
setAvailableMcps(mcps);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
@@ -187,6 +194,7 @@ const Agents: React.FC = () => {
|
||||
memory_enabled: false,
|
||||
reasoning_enabled: false,
|
||||
});
|
||||
setToolPolicy(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
@@ -194,6 +202,7 @@ const Agents: React.FC = () => {
|
||||
const openEdit = async (agent: Agent) => {
|
||||
setEditingAgent(agent);
|
||||
await loadAvailable();
|
||||
await openEditLoadPolicy(agent.id);
|
||||
|
||||
const cfg = agent.config || {};
|
||||
setForm({
|
||||
@@ -213,7 +222,15 @@ const Agents: React.FC = () => {
|
||||
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) => {
|
||||
setDetailAgent(agent);
|
||||
setShowDetail(true);
|
||||
@@ -246,7 +263,13 @@ const Agents: React.FC = () => {
|
||||
try {
|
||||
if (editingAgent) {
|
||||
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 {
|
||||
await adminAPI.createAdminAgent(payload);
|
||||
showToast('Agent 创建成功');
|
||||
@@ -612,6 +635,87 @@ const Agents: React.FC = () => {
|
||||
</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 className="modal-footer">
|
||||
<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;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Phase 3.2: 放宽 market_apps.agent_api_key 容量以容纳 Fernet 密文
|
||||
SET @db = DATABASE();
|
||||
SET @tbl = 'market_apps';
|
||||
SET @col = 'agent_api_key';
|
||||
SELECT COLUMN_TYPE INTO @ctype FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME=@tbl AND COLUMN_NAME=@col;
|
||||
SET @sql = IF(@ctype IS NULL OR @ctype NOT LIKE '%text%',
|
||||
CONCAT('ALTER TABLE ', @tbl, ' MODIFY COLUMN ', @col, ' TEXT NULL COMMENT \'Agent 接入 API Key(3.2 起 Fernet 加密存储)\''),
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
Reference in new issue
Block a user