Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b34ba78b8 | ||
|
|
0600c37b9e |
No files matched your search
@@ -555,3 +555,56 @@ async def get_available_models(
|
|||||||
for m, p in models
|
for m, p in models
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3.1 tool_policy 沙箱:Agent 工具/MCP 权限策略
|
||||||
|
# ============================================================
|
||||||
|
class ToolPolicyModel(BaseModel):
|
||||||
|
tool_policy: Optional[dict] = None # null = 清除策略(恢复默认允许全部)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/tool-policy", summary="获取 Agent 工具策略")
|
||||||
|
async def get_agent_tool_policy(
|
||||||
|
agent_id: str,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
agent = db.query(MarketApp).filter(
|
||||||
|
MarketApp.id == agent_id, MarketApp.type == "agent"
|
||||||
|
).first()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||||
|
return {"agent_id": agent_id, "tool_policy": agent.tool_policy}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{agent_id}/tool-policy", summary="设置 Agent 工具策略")
|
||||||
|
async def set_agent_tool_policy(
|
||||||
|
agent_id: str,
|
||||||
|
body: ToolPolicyModel,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
agent = db.query(MarketApp).filter(
|
||||||
|
MarketApp.id == agent_id, MarketApp.type == "agent"
|
||||||
|
).first()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||||
|
tp = body.tool_policy
|
||||||
|
if tp is not None:
|
||||||
|
if not isinstance(tp, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="tool_policy 必须为对象")
|
||||||
|
# 轻量结构校验
|
||||||
|
if "allowed_tools" in tp and tp["allowed_tools"] is not None:
|
||||||
|
at = tp["allowed_tools"]
|
||||||
|
if not isinstance(at, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="allowed_tools 必须为对象")
|
||||||
|
for k in ("skill_ids", "mcp_ids"):
|
||||||
|
if k in at and at[k] is not None and not isinstance(at[k], list):
|
||||||
|
raise HTTPException(status_code=400, detail=f"allowed_tools.{k} 必须为数组")
|
||||||
|
for k in ("max_calls_per_conversation", "per_round_cap"):
|
||||||
|
if k in tp and tp[k] is not None and not isinstance(tp[k], int):
|
||||||
|
raise HTTPException(status_code=400, detail=f"{k} 必须为整数")
|
||||||
|
agent.tool_policy = tp
|
||||||
|
db.commit()
|
||||||
|
return {"agent_id": agent_id, "tool_policy": agent.tool_policy, "updated": True}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
||||||
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
||||||
|
|
||||||
|
from app.core.credential import decrypt_secret
|
||||||
|
from app.core.observability import get_trace_id
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, desc, asc, func
|
from sqlalchemy import and_, desc, asc, func
|
||||||
@@ -900,7 +902,13 @@ async def _call_external_agent(db, agent: dict, query: str, user_id: str,
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
api_key = agent.get("agent_api_key") or ""
|
_row = db.query(MarketApp).filter(MarketApp.id == agent.get("id")).first()
|
||||||
|
_raw = _row.agent_api_key if _row else None
|
||||||
|
try:
|
||||||
|
api_key = decrypt_secret(_raw) or ""
|
||||||
|
except Exception as _e:
|
||||||
|
api_key = ""
|
||||||
|
logging.error("[credential] 解密 Agent 凭据失败 id=%s trace=%s: %s" % (agent.get("id"), get_trace_id(), _e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1083,7 +1083,11 @@ async def _send_message_core(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 3.1 tool_policy 沙箱:聚合策略 + 初始化预算计数器 =====
|
||||||
|
_agent_tool_policy = _resolve_agent_tool_policy(agents)
|
||||||
|
_tool_call_count = 0
|
||||||
for round_num in range(max_tool_rounds):
|
for round_num in range(max_tool_rounds):
|
||||||
|
_round_tool_count = 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1297,6 +1301,26 @@ async def _send_message_core(
|
|||||||
|
|
||||||
|
|
||||||
func_name = tc.get("function", {}).get("name", "")
|
func_name = tc.get("function", {}).get("name", "")
|
||||||
|
# ===== 3.1 tool_policy 沙箱:逐工具校验(白名单 + 预算 + 每轮上限) =====
|
||||||
|
_mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None
|
||||||
|
_policy_check = _check_tool_policy(func_name, _mcp_info, skills, _agent_tool_policy, _tool_call_count, _round_tool_count)
|
||||||
|
if not _policy_check["allowed"]:
|
||||||
|
_reason = _policy_check["reason"]
|
||||||
|
logger.warning(
|
||||||
|
f"[tool_policy] blocked tool call: agent={_agent_tool_policy.get('agent_id') if _agent_tool_policy else None} "
|
||||||
|
f"tool={func_name} reason={_reason} trace={get_trace_id()}"
|
||||||
|
)
|
||||||
|
tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}"
|
||||||
|
messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc.get("id", f"call_{round_num}"),
|
||||||
|
"content": tool_result,
|
||||||
|
})
|
||||||
|
_tool_call_count += 1
|
||||||
|
_round_tool_count += 1
|
||||||
|
continue
|
||||||
|
_tool_call_count += 1
|
||||||
|
_round_tool_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2009,3 +2033,58 @@ async def _send_message_core(
|
|||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# 3.1 tool_policy 沙箱辅助函数
|
||||||
|
# ======================================================================
|
||||||
|
def _resolve_agent_tool_policy(agents):
|
||||||
|
"""从已解析的 agents 聚合工具策略。多 agent 时取首个带有非空策略者。"""
|
||||||
|
if not agents:
|
||||||
|
return None
|
||||||
|
for ag in agents:
|
||||||
|
tp = ag.get("tool_policy")
|
||||||
|
if tp and isinstance(tp, dict):
|
||||||
|
tp = dict(tp)
|
||||||
|
tp["agent_id"] = ag.get("id")
|
||||||
|
return tp
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count):
|
||||||
|
"""校验单个工具调用是否被策略允许。
|
||||||
|
|
||||||
|
返回 {"allowed": bool, "reason": str, "tool_id": str|None, "message": str}
|
||||||
|
policy 为 None/空 -> 允许(向后兼容)。
|
||||||
|
"""
|
||||||
|
if not policy:
|
||||||
|
return {"allowed": True, "tool_id": None}
|
||||||
|
allowed = policy.get("allowed_tools") or {}
|
||||||
|
skill_ids = allowed.get("skill_ids") or []
|
||||||
|
mcp_ids = allowed.get("mcp_ids") or []
|
||||||
|
is_mcp = func_name.startswith("mcp_tool_")
|
||||||
|
tool_id = None
|
||||||
|
if is_mcp:
|
||||||
|
if mcp_info and isinstance(mcp_info, dict):
|
||||||
|
tool_id = mcp_info.get("server_id")
|
||||||
|
if mcp_ids and tool_id not in mcp_ids:
|
||||||
|
return {"allowed": False, "reason": "mcp_not_in_allowlist", "tool_id": tool_id,
|
||||||
|
"message": "MCP 服务不在该智能体的允许列表中"}
|
||||||
|
else:
|
||||||
|
if skills:
|
||||||
|
_s = next((s for s in skills if s.get("func_name") == func_name), None)
|
||||||
|
if _s:
|
||||||
|
tool_id = _s.get("id")
|
||||||
|
if skill_ids and tool_id not in skill_ids:
|
||||||
|
return {"allowed": False, "reason": "skill_not_in_allowlist", "tool_id": tool_id,
|
||||||
|
"message": "技能不在该智能体的允许列表中"}
|
||||||
|
budget = policy.get("max_calls_per_conversation")
|
||||||
|
if budget is not None and call_count >= int(budget):
|
||||||
|
return {"allowed": False, "reason": "budget_exceeded", "tool_id": tool_id,
|
||||||
|
"message": policy.get("fallback_message")
|
||||||
|
or "该智能体已达到工具调用上限,请开启新对话或调整智能体设置。"}
|
||||||
|
per_round = policy.get("per_round_cap")
|
||||||
|
if per_round is not None and round_count >= int(per_round):
|
||||||
|
return {"allowed": False, "reason": "per_round_cap", "tool_id": tool_id,
|
||||||
|
"message": "本轮工具调用已达上限"}
|
||||||
|
return {"allowed": True, "tool_id": tool_id}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
||||||
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
||||||
|
|
||||||
|
from app.core.credential import decrypt_secret, mask_secret
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, desc, asc, func
|
from sqlalchemy import and_, desc, asc, func
|
||||||
@@ -513,6 +514,7 @@ def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"tool_policy": ag.tool_policy,
|
||||||
# 外部 Agent 接入(Dify/n8n/Coze)运行期远程调用所需字段:
|
# 外部 Agent 接入(Dify/n8n/Coze)运行期远程调用所需字段:
|
||||||
|
|
||||||
|
|
||||||
@@ -529,7 +531,7 @@ def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"agent_api_key": ag.agent_api_key or None,
|
"agent_api_key": mask_secret(decrypt_secret(ag.agent_api_key)) or None,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from app.core.credential import encrypt_secret
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, desc, asc, func
|
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,
|
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,
|
join_promotion=req.join_promotion if req.join_promotion is not None else False,
|
||||||
agent_endpoint=req.agent_endpoint,
|
agent_endpoint=req.agent_endpoint,
|
||||||
agent_api_key=req.api_key,
|
agent_api_key=encrypt_secret(req.api_key),
|
||||||
status="published", # 用户创建的应用直接发布
|
status="published", # 用户创建的应用直接发布
|
||||||
created_at=datetime.now(),
|
created_at=datetime.now(),
|
||||||
updated_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:]
|
||||||
@@ -5,6 +5,7 @@ AIFlow 数据库模型定义 - SQLAlchemy ORM
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Text,
|
||||||
Column, String, Integer, BigInteger, Float, Boolean,
|
Column, String, Integer, BigInteger, Float, Boolean,
|
||||||
DateTime, Date, Text, Enum, ForeignKey, Index, JSON,
|
DateTime, Date, Text, Enum, ForeignKey, Index, JSON,
|
||||||
DECIMAL, UniqueConstraint
|
DECIMAL, UniqueConstraint
|
||||||
@@ -299,7 +300,8 @@ class MarketApp(Base):
|
|||||||
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
|
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
|
||||||
capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video")
|
capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video")
|
||||||
agent_endpoint = Column(String(500), default=None)
|
agent_endpoint = Column(String(500), default=None)
|
||||||
agent_api_key = Column(String(500), default=None, comment="单个Agent接入的API Key")
|
agent_api_key = Column(Text, default=None, comment="Agent 接入 API Key(3.2 起 Fernet 加密存储)")
|
||||||
|
tool_policy = Column(JSON, default=None, comment="Agent 工具/MCP 权限沙箱策略(3.1)")
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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