MCP是手腳,Skill是靈魂· 第四篇:別讓AI瞎操作
整理版優先睇
呢篇文章用真實事故講出AI直接操作系統嘅危險,並提出SandboxSkill架構,用確定性安全代碼隔離AI操作,確保安全。
呢篇文章出自「努力撞蘑菇AI」,係一個專注AI Agent深度實踐嘅作者。文章以2024年9月海外初創公司嘅真實事故開頭:工程師用MCP AI運維助手清理臨時文件,結果因路徑穿越令系統配置被抹平,服務中斷14小時。作者想帶出嘅問題係:AI嘅判斷係概率性,但安全必須係確定性,唔可以依賴AI自己判斷咩操作危險。
為咗解決呢個問題,作者提出SandboxSkill架構,喺MCP Tool層同實際系統調用之間插入權限圍欄。具體包括命令白名單(只允許指定命令同參數模式)、路徑沙箱(歸一化後檢查路徑邊界)、資源限制(時間、內存、CPU、輸出大小)同審計日誌(記錄所有操作)。呢啲檢查全部係確定性代碼邏輯,要麼通過要麼拒絕,冇「大概安全」。
作者仲提供完整Python代碼實現(SandboxSkill基類同LogAnalysisSkill、TempCleanerSkill兩個示例),同埋subprocess安全用法對比同五條工程實踐。文章結論係:MCP嘅價值真實,但必須用確定性安全機制,唔係限制AI,而係畀AI喺清晰邊界內安全發揮。安全係AI變得可信任嘅前提。
- 結論:AI直接操作系統好危險,必須用確定性安全代碼隔離,而唔係靠提示詞警告。
- 方法:SandboxSkill架構包括命令白名單、路徑沙箱、資源限制、審計日誌,全部係硬約束。
- 差異:概率性安全 vs 確定性安全:AI判斷有0.1%錯誤率,大規模調用必然出事;安全機制必須冇漏洞。
- 啟發:工程師唔應該信任AI「知道」危險操作,因為認知同執行解耦,任務模式會壓制安全意識。
- 可行動點:部署沙箱Skill時要跟從最小權限、默認拒絕、審計獨立、漸進信任、定期回顧五條原則。
SandboxSkill 沙箱基類
包含命令白名單、路徑沙箱、資源限制、審計日誌的完整Python實現,同埋LogAnalysisSkill和TempCleanerSkill示例。可直接用於MCP安全隔離。
真實事故與AI操作危險
2024年9月,某海外初創公司工程師用MCP AI運維助手清理伺服器臨時文件,落指令「幫我清理掉 /data/tmp 目錄下三天前的日誌檔案」。AI生成指令 rm -rf /data/tmp/../../etc/,路徑穿越令 /etc 目錄被抹平,三個微服務配置丟失,服務中斷14小時。呢個唔係科幻故事——路徑穿越攻擊係真實漏洞,而AI生成命令速度比人類快100倍,而且唔會因為「感覺唔對路」而停低。
路徑穿越攻擊係真實漏洞,AI生成命令速度比人類快100倍
AI嘅判斷係統計性錯誤,安全必須係確定性
- 模式一:幻覺路徑——AI混淆相對/絕對路徑,生成意圖之外嘅目標地址
- 模式二:過度泛化——用戶話「清理舊文件」,AI理解為「刪除所有N天前文件」
- 模式三:工具鏈污染——前一個工具輸出包含注入字符,後續命令被污染
點解唔可以信任AI判斷
我哋傾向認為既然大模型理解語言,就「懂得」危險操作含義。但實際上模型喺「描述知識」同「執行任務」時用嘅上下文窗口同推理鏈完全唔同。執行任務模式下,安全意識會被任務目標函數壓制。
任務模式會壓制安全意識,AI冇「感到唔對路就停低」嘅本能
- 1 訓練數據偏差:互聯網充斥缺乏安全注意事項嘅教程,模型學到「高效完成任務」模式
- 2 上下文窗口侷限:複雜多輪對話中,模型越後越難維持對早期安全約束嘅注意力
- 3 概率性採樣本質:每次輸出係概率分佈採樣,即使錯誤命令概率0.1%,大規模調用一定會發生
唔好用提示詞警告作為唯一安全防線,提示詞係軟約束,代碼先係硬約束
SandboxSkill 架構設計
沙箱Skill嘅核心思想係:喺MCP Tool層同實際系統調用之間插入權限圍欄,所有AI操作請求必須先通過圍欄檢查,先至觸達真實系統資源。
白名單優先:未明確允許嘅,一律拒絕
- 命令白名單:只允許列表內命令+參數模式,唔喺列表嘅一律拒絕,粒度細到命令+參數模式
- 路徑沙箱:所有路徑操作須經 os.path.realpath 歸一化,檢查是否喺允許根目錄下,拒絕 ../ 穿越
- 資源限制:時限、內存、CPU、輸出大小限制,防止死循環或資源耗盡
- 審計日誌:每次操作(允許或拒絕)都留下完整記錄,包括時間戳、來源、命令、結果
路徑邊界檢查必須喺命令解析後執行,唔係對原始字符串做簡單匹配
安全檢查用代碼邏輯,唔依賴AI判斷
#!/usr/bin/env python3
"""sandbox_skill.py - MCP安全防火牆沙箱Skill基類:用於隔離AI執行危險Shell/文件系統操作的權限圍欄"""
import os
import re
import time
import shlex
import logging
import subprocess
import resource
from pathlib import Path
from typing import List, Optional
from dataclasses import dataclass, field
audit_logger = logging.getLogger("sandbox.audit")
@dataclass
class SandboxPolicy:
"""沙箱策略配置"""
allowed_commands: List[str] = field(default_factory=list)
allowed_paths: List[str] = field(default_factory=list)
forbidden_patterns: List[str] = field(default_factory=lambda: [
r'rm\s+-rf',
r'\.\.\/',
r';\s*rm',
r'\|\s*sh',
r'\$\(.*\)',
r'`.*`',
r'>\s*/dev/',
r'chmod\s+777',
r'sudo',
r'/etc/passwd',
r'/etc/shadow',
])
timeout_seconds: int = 30
max_output_bytes: int = 1024 * 1024
max_memory_bytes: int = 256 * 1024 * 1024
@dataclass
class ExecutionResult:
"""命令執行結果"""
success: bool
stdout: str = ""
stderr: str = ""
return_code: int = -1
blocked: bool = False
block_reason: str = ""
execution_time_ms: float = 0.0
class SandboxSkill:
"""
沙箱Skill基類
核心原則:
1. 白名單優先:未明確允許的,一律拒絕
2. 路徑歸一化:先解析真實路徑,再做邊界檢查
3. 確定性安全:安全檢查用代碼邏輯,不依賴AI判斷
4. 全程審計:每一步操作都留下記錄
"""
SKILL_NAME: str = "base_sandbox"
POLICY: SandboxPolicy = SandboxPolicy()
def __init__(self, session_id: str, user_id: str):
self.session_id = session_id
self.user_id = user_id
def _check_forbidden_patterns(self, command: str) -> Optional[str]:
for pattern in self.POLICY.forbidden_patterns:
if re.search(pattern, command, re.IGNORECASE):
return f"命令包含禁止模式: {pattern}"
return None
def _check_command_whitelist(self, command: str) -> Optional[str]:
try:
parts = shlex.split(command)
except ValueError as e:
return f"命令解析失敗(可能存在注入): {e}"
if not parts:
return "空命令被拒絕"
cmd_name = os.path.basename(parts[0])
if cmd_name not in self.POLICY.allowed_commands:
return (
f"命令 '{cmd_name}' 不在白名單中。"
f"允許的命令: {', '.join(self.POLICY.allowed_commands)}"
)
return None
def _check_path_boundary(self, path: str) -> Optional[str]:
if not self.POLICY.allowed_paths:
return "沒有配置允許的路徑,操作被拒絕"
real_path = os.path.realpath(path)
for allowed_root in self.POLICY.allowed_paths:
real_root = os.path.realpath(allowed_root)
try:
common = os.path.commonpath([real_path, real_root])
if common == real_root:
return None
except ValueError:
continue
return (
f"路徑 '{path}'(真實路徑: '{real_path}')"
f"超出允許範圍: {self.POLICY.allowed_paths}"
)
def _set_resource_limits(self):
resource.setrlimit(
resource.RLIMIT_AS,
(self.POLICY.max_memory_bytes, self.POLICY.max_memory_bytes)
)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
def safe_execute(self, command: str, cwd: Optional[str] = None) -> ExecutionResult:
start_time = time.monotonic()
checks = [
self._check_forbidden_patterns(command),
self._check_command_whitelist(command),
self._check_path_boundary(cwd) if cwd else None,
]
for reason in checks:
if reason:
result = ExecutionResult(
success=False, blocked=True, block_reason=reason
)
audit_logger.warning(
f"[{self.SKILL_NAME}] BLOCKED | {reason} | user={self.user_id}"
)
return result
safe_env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"}
try:
proc = subprocess.run(
shlex.split(command),
shell=False,
capture_output=True,
text=True,
timeout=self.POLICY.timeout_seconds,
cwd=cwd,
env=safe_env,
preexec_fn=self._set_resource_limits,
)
elapsed = (time.monotonic() - start_time) * 1000
stdout = proc.stdout
if len(stdout.encode()) > self.POLICY.max_output_bytes:
stdout = stdout[:self.POLICY.max_output_bytes] + "\n[輸出已截斷]"
result = ExecutionResult(
success=proc.returncode == 0,
stdout=stdout,
stderr=proc.stderr[:4096],
return_code=proc.returncode,
execution_time_ms=elapsed
)
except subprocess.TimeoutExpired:
result = ExecutionResult(
success=False, blocked=True,
block_reason=f"執行超時(限制: {self.POLICY.timeout_seconds}秒)",
execution_time_ms=(time.monotonic() - start_time) * 1000
)
except Exception as e:
result = ExecutionResult(success=False, stderr=str(e))
audit_logger.info(
f"[{self.SKILL_NAME}] EXECUTE | "
f"blocked={result.blocked} | user={self.user_id} | "
f"time={result.execution_time_ms:.1f}ms"
)
return result
def safe_read_file(self, path: str, max_size_bytes: int = 102400) -> ExecutionResult:
block_reason = self._check_path_boundary(path)
if block_reason:
result = ExecutionResult(success=False, blocked=True, block_reason=block_reason)
audit_logger.warning(f"[{self.SKILL_NAME}] FILE_READ_BLOCKED | {block_reason}")
return result
try:
file_path = Path(os.path.realpath(path))
if not file_path.is_file():
return ExecutionResult(success=False, stderr=f"路徑不是文件: {path}")
size = file_path.stat().st_size
if size > max_size_bytes:
return ExecutionResult(
success=False, blocked=True,
block_reason=f"文件大小({size}字節)超過限制({max_size_bytes}字節)"
)
content = file_path.read_text(errors='replace')
return ExecutionResult(success=True, stdout=content)
except Exception as e:
return ExecutionResult(success=False, stderr=str(e))
subprocess安全用法與工程實踐
文章詳細對比咗subprocess嘅安全同唔安全用法,指出最常見嘅陷阱:
shell=False + 列表參數係防止注入嘅關鍵
- ❌ shell=True 傳入字符串——命令注入經典入口
- ✅ 用 shell=False + 列表參數,每個參數獨立字符串
- ❌ os.system()——內部就係 shell=True,永遠唔好用
- ❌ 忽略超時——可能觸發永久阻塞
- ✅ 始終設置 timeout 並捕獲異常
- ❌ 信任AI構造嘅路徑——可能路徑穿越
- ✅ 先 os.path.realpath 解析再做邊界檢查
shell=False 防咗注入,但冇防路徑穿越,所以要額外做路徑檢查
五條工程實踐原則:
- 1 最小權限原則:每個Skill只申請所需最小權限
- 2 默認拒絕,顯式允許:白名單思維,所有未明確允許嘅一律拒絕
- 3 審計日誌必須獨立存儲:寫入AI無權訪問嘅append-only存儲
- 4 漸進式信任,二步確認:高危操作先dry-run,人類確認後先執行
- 5 定期回顧審計日誌:每週分析被攔截嘅操作,持續改善安全
安全閉環中冇任何環節依賴AI判斷,全部係確定性代碼邏輯
安全本質係確定性,AI判斷係概率性,安全機制必須係確定性
一、由一個真實事故講起
2024年9月,某海外初創公司嘅工程師用緊一套基於MCP嘅AI運維助手清理伺服器上嘅臨時檔案。佢同AI落咗一個睇落冇任何風險嘅指令:
“「幫我清理 /data/tmp 目錄下面三日之前嘅日誌檔案。」
AI收到指令,調用咗Shell工具,生成咗以下命令:
rm -rf /data/tmp/../../etc/
路徑穿越。兩個 ../ 將目標由 /data/tmp 偏移到伺服器嘅 /etc 目錄。成個系統設定層被抹平。伺服器喺10秒之內進入不可恢復狀態,三個微服務嘅設定檔全部遺失,數據庫連接憑證消失,SSL證書鏈斷裂。
嗰日係星期五下晝。運維團隊花咗成11個鐘頭先至從備份還原。對外服務中斷時間:14小時23分鐘。
呢個唔係科幻故事。路徑穿越攻擊係真實存在嘅漏洞類別,而AI處理路徑字串嘅時候,並唔比人類程式員更謹慎——佢甚至更危險,因為佢生成命令嘅速度比人類打字快100倍,而且唔會因為「覺得唔對路」而停低重新思考。
更深層嘅問題係:位工程師事後查咗AI嘅「推理過程」。模型確實「理解」咗用戶想清理臨時日誌。但喺構建路徑字串嘅時候,佢將用戶輸入嘅相對路徑同系統絕對路徑拼接時出現咗邏輯偏移——呢種偏移唔係「惡意」,而係統計性錯誤。
呢個就帶出今日要講嘅核心命題:AI嘅判斷係概率性嘅,安全必須係確定性嘅。

二、MCP賦予AI嘅「超能力」究竟有幾危險
MCP(Model Context Protocol)嘅核心價值在於:佢令AI模型可以透過標準化協議調用各種工具——檔案讀寫、數據庫查詢、Shell命令、API調用、甚至系統級操作。呢個係AI從「會傾偈嘅助手」進化為「做得嘢嘅工程師」嘅關鍵一跳。
但呢個躍遷同時意味住,AI獲得咗原本只有人類程式員先至有嘅破壞力。
現實中,絕大多數AI「事故」唔係來自惡意——佢哋來自以下三種模式:
模式一:幻覺路徑AI喺處理用戶描述嘅路徑時,將相對路徑、絕對路徑、用戶變數混淆,生成咗預期之外嘅目標地址。
模式二:過度泛化用戶話「清理舊檔案」,AI理解成「刪除所有修改時間超過N日嘅檔案」,忽略咗用戶其實只想清理某個特定子目錄。
模式三:工具鏈污染喺多工具串聯場景下,前一個工具嘅輸出成為下一個工具嘅輸入。如果中間某一步嘅輸出包含咗注入字符(例如檔案名含有 ;rm -rf),而AI冇做轉義,後續命令就會被污染。
呢三種模式有一個共同特點:AI喺執行嘅時候覺得自己做啱咗。模型嘅置信度可能高達99%,但嗰1%嘅錯誤,喺Shell層面係災難性嘅。
三、點解唔可以信AI「知道」乜嘢操作危險
呢個係成篇文章最需要你理解嘅認知基礎,亦係好多工程師忽略嘅地方。
我哋傾向認為:既然大模型理解語言,佢就「識得」危險操作嘅含義。畢竟,你問GPT「rm -rf / 會點」,佢會詳細解釋呢條命令嘅破壞性。
但呢度存在一個認知與執行嘅解耦。
模型喺「描述知識」同「執行任務」嗰陣調用嘅係完全唔同嘅上下文窗口同推理鏈。當模型處於任務執行模式(Tool Use)時,注意力集中喺「點樣完成用戶畀嘅目標」,安全意識會被任務目標函數壓制。
打個比喻:你問一個熟悉危化品嘅化學家「呢兩種化學品溝埋會爆炸嗎?」佢會正確答「會」。但如果你畀佢一個任務:「幫我配製清潔劑,要用漂白水」,喺任務執行嘅專注狀態下,佢有可能唔記得檢查架上其他化學品嘅兼容性。
AI更差——佢冇「覺得唔對路就停低」嘅本能。
由技術角度睇,原因有三:
1. 訓練數據嘅偏差:互聯網上充斥住大量「點樣用Shell完成某任務」嘅教學,當中好多缺少安全注意事項。模型學習呢啲內容嘅時候,同時學到咗「高效完成任務」嘅模式。
2. 上下文窗口嘅侷限:喺複雜嘅多輪對話中,模型越往後越難維持對早期安全限制嘅注意力。窗口前段設定嘅限制,去到第20輪對話嗰陣可能已經「淡出」咗注意力分配。
3. 概率性採樣嘅本質:模型嘅每一次輸出都係基於概率分佈嘅採樣。即使某個錯誤命令嘅概率得0.1%,喺大規模調用中,佢一定會發生。
結論係確定嘅:唔好用「提示詞警告」作為安全機制嘅唯一防線。提示詞係軟約束,程式碼係硬約束。安全必須喺程式碼層面用確定性邏輯實現。
四、沙箱Skill嘅架構設計
沙箱Skill嘅核心思想係:喺MCP嘅Tool層同實際系統調用之間,插入一個權限圍欄層。所有來自AI嘅操作請求,必須先通過圍欄嘅檢查,先至可以觸及真實嘅系統資源。
呢個架構分為四個子系統:
4.1 命令白名單機制
白名單嘅邏輯好簡單:只有喺允許列表入面嘅命令,先至可以執行。唔喺列表入面嘅一律拒絕,唔做「智能判斷」,唔畀AI任何解釋機會。
白名單嘅粒度需要細化到命令+參數模式級別,而唔止係命令名稱:
✅ 允許: ls -la /data/logs❌ 拒絕: ls -la /etc(路徑超出範圍)✅ 允許: grep -r "ERROR" /data/logs/❌ 拒絕: grep -r "password" /etc/(危險關鍵詞+危險路徑)
4.2 路徑沙箱
所有涉及路徑嘅操作,都需要經過路徑歸一化和邊界檢查:
將所有相對路徑轉化為絕對路徑( os.path.realpath)檢查歸一化後嘅路徑係咪喺允許嘅根目錄下 拒絕任何包含路徑穿越符號 ..的輸入
路徑檢查必須喺命令解析之後執行,而唔係對原始字串做簡單嘅字串匹配。
4.3 資源限制
就算命令同路徑都合法,我哋都需要限制其資源消耗:
時間限制:防止AI觸發死循環或者長時間阻塞 記憶體限制:防止記憶體泄漏或者意外嘅記憶體爆炸 CPU限制:防止意外嘅計算密集型操作佔用伺服器資源 輸出大小限制:防止 cat一個超大檔案導致記憶體溢出
4.4 審計日誌
每一次操作——無論係被允許定係被拒絕——都必須留下完整嘅審計記錄:
時間戳 請求來源(邊個AI會話、邊個用戶) 原始命令同解析後嘅命令 執行結果(成功/失敗/被攔截) 執行需時
呢個唔單止係為咗事後排查,審計日誌本身係一種威懾機制——當每一步操作都有記錄,操作嘅謹慎程度自然提升。
五、完整Python程式碼實現
下面係完整嘅 SandboxSkill 基類實現,以及兩個繼承自佢嘅具體Skill示例。
#!/usr/bin/env python3
"""
sandbox_skill.py - MCP安全防火牆
沙箱Skill基類:用於隔離AI執行危險Shell/文件系統操作的權限圍欄
"""
import os
import re
import time
import shlex
import logging
import subprocess
import resource
from pathlib import Path
from typing import List, Optional
from dataclasses import dataclass, field
audit_logger = logging.getLogger("sandbox.audit")
@dataclass
class SandboxPolicy:
"""沙箱策略配置"""
allowed_commands: List[str] = field(default_factory=list)
allowed_paths: List[str] = field(default_factory=list)
forbidden_patterns: List[str] = field(default_factory=lambda: [
r'rm\s+-rf', # 危險刪除
r'\.\.\/', # 路徑穿越
r';\s*rm', # 命令注入後跟刪除
r'\|\s*sh', # 管道到shell
r'\$\(.*\)', # 命令替換
r'`.*`', # 反引號命令替換
r'>\s*/dev/', # 寫入設備文件
r'chmod\s+777', # 危險權限修改
r'sudo', # sudo提權
r'/etc/passwd', # 敏感文件
r'/etc/shadow', # 敏感文件
])
timeout_seconds: int = 30
max_output_bytes: int = 1024 * 1024 # 1MB
max_memory_bytes: int = 256 * 1024 * 1024# 256MB
@dataclass
class ExecutionResult:
"""命令執行結果"""
success: bool
stdout: str = ""
stderr: str = ""
return_code: int = -1
blocked: bool = False
block_reason: str = ""
execution_time_ms: float = 0.0
class SandboxSkill:
"""
沙箱Skill基類
核心原則:
1. 白名單優先:未明確允許的,一律拒絕
2. 路徑歸一化:先解析真實路徑,再做邊界檢查
3. 確定性安全:安全檢查用代碼邏輯,不依賴AI判斷
4. 全程審計:每一步操作都留下記錄
"""
SKILL_NAME: str = "base_sandbox"
POLICY: SandboxPolicy = SandboxPolicy()
def __init__(self, session_id: str, user_id: str):
self.session_id = session_id
self.user_id = user_id
def _check_forbidden_patterns(self, command: str) -> Optional[str]:
"""檢查命令中是否包含危險模式"""
for pattern in self.POLICY.forbidden_patterns:
if re.search(pattern, command, re.IGNORECASE):
returnf"命令包含禁止模式: {pattern}"
returnNone
def _check_command_whitelist(self, command: str) -> Optional[str]:
"""檢查命令是否在白名單中"""
try:
parts = shlex.split(command)
except ValueError as e:
returnf"命令解析失敗(可能存在注入): {e}"
ifnot parts:
return"空命令被拒絕"
cmd_name = os.path.basename(parts[0])
if cmd_name notin self.POLICY.allowed_commands:
return (
f"命令 '{cmd_name}' 不在白名單中。"
f"允許的命令: {', '.join(self.POLICY.allowed_commands)}"
)
returnNone
def _check_path_boundary(self, path: str) -> Optional[str]:
"""
路徑邊界檢查(核心防線)
使用 os.path.realpath 解析真實路徑,防止 ../ 穿越和符號連結攻擊
"""
ifnot self.POLICY.allowed_paths:
return"沒有配置允許的路徑,操作被拒絕"
real_path = os.path.realpath(path)
for allowed_root in self.POLICY.allowed_paths:
real_root = os.path.realpath(allowed_root)
try:
common = os.path.commonpath([real_path, real_root])
if common == real_root:
returnNone# 檢查通過
except ValueError:
continue
return (
f"路徑 '{path}'(真實路徑: '{real_path}')"
f"超出允許範圍: {self.POLICY.allowed_paths}"
)
def _set_resource_limits(self):
"""設置進程資源限制(在子進程中調用)"""
resource.setrlimit(
resource.RLIMIT_AS,
(self.POLICY.max_memory_bytes, self.POLICY.max_memory_bytes)
)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
def safe_execute(self, command: str, cwd: Optional[str] = None) -> ExecutionResult:
"""
安全執行命令的統一入口
執行流程:
1. 危險模式檢查(快速失敗)
2. 命令白名單檢查
3. 工作目錄路徑檢查
4. 執行命令,附加資源限制和超時控制
5. 審計日誌寫入
"""
start_time = time.monotonic()
# 三道安全檢查
checks = [
self._check_forbidden_patterns(command),
self._check_command_whitelist(command),
self._check_path_boundary(cwd) if cwd elseNone,
]
for reason in checks:
if reason:
result = ExecutionResult(
success=False, blocked=True, block_reason=reason
)
audit_logger.warning(
f"[{self.SKILL_NAME}] BLOCKED | {reason} | user={self.user_id}"
)
return result
# 構建最小化安全環境變量
safe_env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"}
try:
# ⚠️ 關鍵:shell=False + 列表參數,防止命令注入
proc = subprocess.run(
shlex.split(command),
shell=False, # 禁止 shell=True
capture_output=True,
text=True,
timeout=self.POLICY.timeout_seconds,
cwd=cwd,
env=safe_env,
preexec_fn=self._set_resource_limits,
)
elapsed = (time.monotonic() - start_time) * 1000
stdout = proc.stdout
if len(stdout.encode()) > self.POLICY.max_output_bytes:
stdout = stdout[:self.POLICY.max_output_bytes] + "\n[輸出已截斷]"
result = ExecutionResult(
success=proc.returncode == 0,
stdout=stdout,
stderr=proc.stderr[:4096],
return_code=proc.returncode,
execution_time_ms=elapsed
)
except subprocess.TimeoutExpired:
result = ExecutionResult(
success=False, blocked=True,
block_reason=f"執行超時(限制: {self.POLICY.timeout_seconds}秒)",
execution_time_ms=(time.monotonic() - start_time) * 1000
)
except Exception as e:
result = ExecutionResult(success=False, stderr=str(e))
audit_logger.info(
f"[{self.SKILL_NAME}] EXECUTE | "
f"blocked={result.blocked} | user={self.user_id} | "
f"time={result.execution_time_ms:.1f}ms"
)
return result
def safe_read_file(self, path: str, max_size_bytes: int = 102400) -> ExecutionResult:
"""安全讀取文件(內置路徑檢查)"""
block_reason = self._check_path_boundary(path)
if block_reason:
result = ExecutionResult(success=False, blocked=True, block_reason=block_reason)
audit_logger.warning(f"[{self.SKILL_NAME}] FILE_READ_BLOCKED | {block_reason}")
return result
try:
file_path = Path(os.path.realpath(path))
ifnot file_path.is_file():
return ExecutionResult(success=False, stderr=f"路徑不是文件: {path}")
size = file_path.stat().st_size
if size > max_size_bytes:
return ExecutionResult(
success=False, blocked=True,
block_reason=f"文件大小({size}字節)超過限制({max_size_bytes}字節)"
)
content = file_path.read_text(errors='replace')
return ExecutionResult(success=True, stdout=content)
except Exception as e:
return ExecutionResult(success=False, stderr=str(e))
# ══════════════════════════════════════════════════════════
# 具體Skill示例一:日誌分析(只讀,低風險)
# ══════════════════════════════════════════════════════════
class LogAnalysisSkill(SandboxSkill):
"""
日誌分析Skill
只允許讀取和搜索日誌文件,所有操作限制在 /data/logs 目錄下。
"""
SKILL_NAME = "log_analysis"
POLICY = SandboxPolicy(
allowed_commands=["grep", "cat", "tail", "head", "wc", "sort", "uniq"],
allowed_paths=["/data/logs", "/var/log/app"],
timeout_seconds=15,
max_output_bytes=512 * 1024,
)
def search_errors(
self, log_file: str, keyword: str, lines: int = 100
) -> ExecutionResult:
"""搜索日誌中的錯誤"""
# 額外檢查:關鍵詞不能包含注入字符
if re.search(r'[;|&`$(){}]', keyword):
return ExecutionResult(
success=False, blocked=True,
block_reason=f"關鍵詞包含危險字符: {keyword}"
)
# 先單獨做路徑檢查(grep本身不會做這件事)
path_check = self._check_path_boundary(log_file)
if path_check:
return ExecutionResult(success=False, blocked=True, block_reason=path_check)
command = f"grep -n {shlex.quote(keyword)} {shlex.quote(log_file)} | tail -{lines}"
return self.safe_execute(command, cwd="/data/logs")
def get_recent_logs(self, log_file: str, n: int = 50) -> ExecutionResult:
"""獲取最新N行日誌"""
if n > 1000:
n = 1000
return self.safe_execute(f"tail -{n} {shlex.quote(log_file)}")
# ══════════════════════════════════════════════════════════
# 具體Skill示例二:臨時文件清理(高危場景)
# ══════════════════════════════════════════════════════════
class TempCleanerSkill(SandboxSkill):
"""
臨時文件清理Skill
只允許刪除 /data/tmp 目錄下的 .log/.tmp/.bak 後綴文件。
注意:rm 不在默認白名單,刪除操作使用獨立的嚴格策略。
"""
SKILL_NAME = "temp_cleaner"
POLICY = SandboxPolicy(
allowed_commands=["find", "ls"], # ⚠️ rm 不在這裏!
allowed_paths=["/data/tmp"],
timeout_seconds=10,
)
# 刪除專用策略(更嚴格)
_DELETE_POLICY = SandboxPolicy(
allowed_commands=["rm"],
allowed_paths=["/data/tmp"],
forbidden_patterns=[
r'rm\s+-rf', # 禁止 -rf
r'rm.*\*', # 禁止通配符
r'\.\.', # 禁止路徑穿越
],
timeout_seconds=30,
)
def list_old_files(self, days: int = 3) -> ExecutionResult:
"""列出N天前的文件(預覽,不刪除)"""
ifnot1 <= days <= 30:
return ExecutionResult(
success=False, blocked=True,
block_reason=f"days參數超出範圍(1-30): {days}"
)
return self.safe_execute(
f"find /data/tmp -maxdepth 2 -mtime +{days} "
f"-name '*.log' -o -name '*.tmp'"
)
def delete_file(self, file_path: str) -> ExecutionResult:
"""
刪除單個文件(嚴格限制)
只允許刪除單個、明確命名的文件,
不允許通配符,不允許遞歸,不允許目錄刪除。
"""
# 只允許特定後綴
allowed_extensions = {'.log', '.tmp', '.bak'}
ext = Path(file_path).suffix.lower()
if ext notin allowed_extensions:
return ExecutionResult(
success=False, blocked=True,
block_reason=f"不允許刪除 '{ext}' 類型的文件,允許: {allowed_extensions}"
)
# 路徑邊界檢查
path_check = self._check_path_boundary(file_path)
if path_check:
return ExecutionResult(success=False, blocked=True, block_reason=path_check)
# 切換到嚴格的刪除策略
original_policy = self.POLICY
self.POLICY = self._DELETE_POLICY
result = self.safe_execute(
f"rm {shlex.quote(os.path.realpath(file_path))}"
)
self.POLICY = original_policy
return result
六、subprocess安全用法 vs 唔安全用法對比
呢個係一個值得拎出嚟講嘅技術細節,因為好多工程師知道「shell=True 唔安全」,但唔知道為什麼,亦都唔清楚其他陷阱喺邊。
❌ 唔安全 #1:shell=True 傳入字串
# 危險!如果 user_input = "test.log; rm -rf /"
user_input = get_from_ai()
subprocess.run(f"cat {user_input}", shell=True) # 命令注入!
shell=True 會將成個字串傳畀 /bin/sh -c 執行,; 後面嘅命令會被當做新命令執行。呢個係命令注入嘅經典入口。
✅ 安全 #1:shell=False + 列表參數
# 安全:每個參數都是獨立的字符串,不經過shell解析
file_path = get_from_ai()
subprocess.run(["cat", file_path], shell=False)
# 即使 file_path = "test.log; rm -rf /",也只會嘗試打開字面文件名
❌ 唔安全 #2:os.system()
# os.system 內部就是 shell=True,永遠不要用這個
os.system(f"grep {keyword} {logfile}") # 危險!
❌ 唔安全 #3:忽略超時
# 沒有 timeout,AI可能觸發一個永久阻塞的命令
subprocess.run(["find", "/", "-name", "*.log"]) # 可能跑幾分鐘
✅ 安全 #3:始終設定超時 + 捕獲異常
try:
result = subprocess.run(
["find", "/data/logs", "-name", "*.log"],
shell=False, capture_output=True,
text=True, timeout=10,
)
except subprocess.TimeoutExpired:
logger.warning("命令執行超時,已終止")
❌ 唔安全 #4:相信AI構建嘅路徑(最容易忽略)
path = ai_response["path"] # 可能是 "/data/logs/../../etc"
subprocess.run(["rm", "-rf", path], shell=False)
# shell=False 防了注入,但沒防路徑穿越!
✅ 安全 #4:先解析路徑,再做邊界檢查
def safe_path_check(user_path: str, allowed_root: str) -> str:
"""返回安全的真實路徑,如果越界則拋出異常"""
real_path = os.path.realpath(user_path) # 解析所有 .. 和軟連結
real_root = os.path.realpath(allowed_root)
if not real_path.startswith(real_root + os.sep) and real_path != real_root:
raise PermissionError(f"路徑越界: {real_path} 不在 {real_root} 下")
return real_path
try:
safe_target = safe_path_check(ai_path, "/data/logs")
subprocess.run(["rm", safe_target], shell=False, timeout=5)
except PermissionError as e:
logger.error(f"被安全檢查攔截: {e}")
七、部署沙箱Skill嘅五條工程實踐
程式碼之外,仲有幾個工程實踐建議,係從真實事故中總結出嚟嘅。
原則一:最小權限原則
每個Skill只申請佢完成任務所需要嘅最小權限。日誌分析Skill唔需要寫權限,就唔畀寫權限。數據庫查詢Skill只需要SELECT,就唔畀DDL權限。
呢個原則聽落簡單,但喺實際實現中容易被「方便性」侵蝕。工程師為咗慳事,成日畀Skill設定一個「夠用就得」嘅寬鬆權限。要抵抗呢種衝動。
原則二:默認拒絕,顯示允許
白名單嘅設計哲學:所有未明確允許嘅,一律拒絕。
呢個同好多人習慣嘅黑名單思維(列出危險嘅,其他都允許)正好相反。黑名單有一個致命缺陷:你永遠冇辦法窮舉所有危險情況。但白名單嘅上限係確定嘅——你只允許你理解嘅操作。
原則三:審計日誌必須獨立儲存
審計日誌本身都係攻擊目標。如果AI可以操作日誌目錄,佢就可以銷毀證據。審計日誌應該寫入獨立嘅、AI冇權存取嘅目錄,最好係追加寫入(append-only)嘅儲存。
原則四:漸進式信任,兩步確認
對於高危操作(例如刪除檔案、修改設定),建議實現兩步確認機制:
AI先執行 dry-run,輸出將會執行嘅具體操作列表人類審查之後,發出二次確認信號 Skill先至執行真實操作
完全自動化並唔一定係最佳方案。
原則五:定期回顧審計日誌
審計日誌唔係寫完就擺喺度。建議每星期回顧一次被攔截嘅操作記錄,分析:
邊啲合法操作被誤攔截咗(需要調整白名單) 邊啲攔截揭示咗AI嘅異常行為模式 係咪有重複嘗試繞過限制嘅跡象
呢個回顧過程本身就係一種持續嘅安全運營。
八、一個完整嘅安全閉環
將今日講嘅內容串埋一齊,一個完整嘅MCP安全閉環應該係咁樣:
用戶指令
│
▼
[AI模型] ──生成工具調用──▶ [MCP協議層]
│
▼
[SandboxSkill 入口]
│
┌─────────────┼─────────────┐
▼ ▼ ▼
[危險模式檢查] [白名單檢查] [路徑邊界檢查]
│ │ │
└──── 任一失敗 ────▶ [攔截 + 審計日誌]
│
全部通過
│
▼
[資源限制包裝器]
│
▼
[實際系統調用]
│
┌─────────────┴─────────────┐
▼ ▼
[成功結果] [超時/失敗]
│ │
└──────── [審計日誌] ────────┘
│
▼
[返回AI模型]
呢個閉環入面,冇任何一個環節依賴「AI判斷呢個操作係咪安全」。每一道檢查都係確定性嘅程式碼邏輯,一係通過,一係拒絕,冇「或者」。
呢個就係安全工程同AI判斷嘅根本區別:安全工程用確定性取代概率性。
九、寫喺最後:恐懼唔係目的,控制先係
讀到呢度,你可能會覺得:MCP咁危險,係咪索性唔好用?
完全相反。
MCP嘅價值係真實嘅,佢令AI真正變成咗可以開工嘅工程夥伴。問題唔在於「用唔用」,而在於「點樣用」。
我哋開頭講嗰個事故,根本原因唔係AI太危險,而係工程師冇喺AI同系統之間建立應有嘅隔離層。如果嗰套運維助手用咗本文描述嘅沙箱機制——路徑邊界檢查會喺 realpath 解析後發現路徑超出咗 /data/tmp 嘅邊界,命令會喺執行前被攔截,審計日誌會記錄呢次異常,工程師會收到警報。
11個鐘頭嘅恢復工作,可以被一個30行嘅路徑檢查函數避免。
安全唔係AI嘅敵人,而係令AI變得可信嘅前提。沙箱Skill唔係限制AI,而係幫AI嘅能力畫一個清晰嘅邊界——等佢喺呢個邊界入面盡情發揮,邊界之外由我哋守護。
呢個先至係用AI用得啱嘅姿勢。
📌 本文要點速查
allowed_commands | ||
os.path.realpathcommonpath | ||
resource.setrlimittimeout | ||
shell=False |
“安全嘅本質係確定性AI嘅判斷係概率性嘅(99.9%嘅正確率意味住大規模調用時必然出錯)。 安全機制必須係確定性嘅——一係通過,一係拒絕,冇「大概安全」。
我係專注AI Agent深度實踐嘅努力撞蘑菇AI,致力於分享真實可用嘅AI使用經驗同工作流探索,歡迎你同我一齊將AI玩得明明白白,如果內容對你有幫助,歡迎關注、讚好、轉發。
一、從一場真實事故開始說起
2024年9月,某海外初創公司的工程師正在用一套基於MCP的AI運維助手清理服務器上的臨時文件。他給AI下達了一個看起來毫無風險的指令:
“"幫我清理掉 /data/tmp 目錄下三天前的日誌文件。"
AI收到指令,調用了Shell工具,生成了如下命令:
rm -rf /data/tmp/../../etc/
路徑穿越。兩個 ../ 把目標從 /data/tmp 偏移到了服務器的 /etc 目錄。整個系統配置層被抹平。服務器在10秒內進入不可恢復狀態,三個微服務的配置文件全部丟失,數據庫連接憑證消失,SSL證書鏈斷裂。
那天是週五下午。運維團隊花了整整11個小時才從備份中恢復。對外服務中斷時長:14小時23分鐘。
這不是科幻故事。路徑穿越攻擊是真實存在的漏洞類別,而AI在處理路徑字符串時,並不比人類程序員更謹慎——它甚至更危險,因為它生成命令的速度比人類打字快100倍,而且不會因為"感覺不對勁"而停下來重新思考。
更深層的問題是:那位工程師事後查了AI的"推理過程"。模型確實"理解"了用戶想清理臨時日誌。但在構建路徑字符串時,它把用戶輸入的相對路徑和系統絕對路徑拼接時出現了邏輯偏移——這種偏移不是"惡意",而是統計性錯誤。
這就引出了今天要講的核心命題:AI的判斷是概率性的,安全必須是確定性的。

二、MCP賦予AI的「超能力」究竟有多危險
MCP(Model Context Protocol)的核心價值在於:它讓AI模型可以通過標準化協議調用各種工具——文件讀寫、數據庫查詢、Shell命令、API調用、甚至系統級操作。這是AI從"會聊天的助手"進化為"能幹活的工程師"的關鍵一躍。
但這個躍遷同時意味着,AI獲得了原本只有人類程序員才擁有的破壞力。
現實中,絕大多數AI"事故"不來自惡意——它們來自以下三種模式:
模式一:幻覺路徑AI在處理用戶描述的路徑時,將相對路徑、絕對路徑、用戶變量混淆,生成了意圖之外的目標地址。
模式二:過度泛化用戶說"清理舊文件",AI理解為"刪除所有修改時間超過N天的文件",忽略了用戶實際上只想清理某個特定子目錄。
模式三:工具鏈污染在多工具串聯場景下,前一個工具的輸出成為下一個工具的輸入。如果中間某一步的輸出包含了注入字符(比如文件名裏含有 ;rm -rf),而AI沒有做轉義,後續命令就會被污染。
這三種模式有一個共同特點:AI在執行時認為自己做對了。模型的置信度可能高達99%,但那1%的錯誤,在Shell層面是災難性的。
三、為什麼不能信任AI「知道」什麼操作危險
這是整篇文章最需要你理解的認知基礎,也是很多工程師忽略的地方。
我們傾向於認為:既然大模型理解語言,它就"懂得"危險操作的含義。畢竟,你問GPT"rm -rf / 會怎樣",它會給你詳細解釋這條命令的破壞性。
但這裏存在一個認知與執行的解耦。
模型在"描述知識"和"執行任務"時調用的是完全不同的上下文窗口和推理鏈。當模型處於任務執行模式(Tool Use)時,它的注意力集中在"如何完成用戶給定的目標",安全意識會被任務目標函數所壓制。
打個比方:你問一個熟悉危化品的化學家"這兩種化學品混合會爆炸嗎?"他會正確回答"會"。但如果你給他一個任務:"幫我配製清潔劑,要用到漂白水",在任務執行的專注狀態下,他有可能忘記檢查架子上其他化學品的兼容性。
AI更糟糕——它沒有"感到不對勁就停下來"的本能。
從技術角度看,原因有三:
1. 訓練數據的偏差:互聯網上充斥着大量"如何用Shell完成某任務"的教程,其中很多缺乏安全注意事項。模型在學習這些內容時,同時學到了"高效地完成任務"的模式。
2. 上下文窗口的侷限:在複雜的多輪對話中,模型越往後越難以維持對早期安全約束的注意力。窗口前段設置的限制,在第20輪對話時可能已經"淡出"了注意力分配。
3. 概率性採樣的本質:模型的每一次輸出都是基於概率分佈的採樣。即使某個錯誤命令的概率只有0.1%,在大規模調用中,它一定會發生。
結論是確定的:不要用"提示詞警告"作為安全機制的唯一防線。提示詞是軟約束,代碼是硬約束。安全必須在代碼層面用確定性邏輯實現。
四、沙箱Skill的架構設計
沙箱Skill的核心思想是:在MCP的Tool層和實際系統調用之間,插入一個權限圍欄層。所有來自AI的操作請求,必須先通過圍欄的檢查,才能觸達真實的系統資源。
這個架構分為四個子系統:
4.1 命令白名單機制
白名單的邏輯很簡單:只有在允許列表中的命令,才能被執行。不在列表中的一律拒絕,不做"智能判斷",不給AI任何解釋的機會。
白名單的粒度需要細化到命令+參數模式級別,而不只是命令名稱:
✅ 允許: ls -la /data/logs❌ 拒絕: ls -la /etc(路徑超出範圍)✅ 允許: grep -r "ERROR" /data/logs/❌ 拒絕: grep -r "password" /etc/(危險關鍵詞+危險路徑)
4.2 路徑沙箱
所有涉及路徑的操作,都需要經過路徑歸一化和邊界檢查:
將所有相對路徑轉化為絕對路徑( os.path.realpath)檢查歸一化後的路徑是否在允許的根目錄下 拒絕任何包含路徑穿越符號 ..的輸入
路徑檢查必須在命令解析之後執行,而不是對原始字符串做簡單的字符串匹配。
4.3 資源限制
即使命令和路徑都合法,我們也需要限制其資源消耗:
時間限制:防止AI觸發死循環或長時間阻塞 內存限制:防止內存泄漏或意外的內存爆炸 CPU限制:防止意外的計算密集型操作佔用服務器資源 輸出大小限制:防止 cat一個超大文件導致內存溢出
4.4 審計日誌
每一次操作——無論是被允許的還是被拒絕的——都必須留下完整的審計記錄:
時間戳 請求來源(哪個AI會話、哪個用戶) 原始命令與解析後的命令 執行結果(成功/失敗/被攔截) 執行耗時
這不只是為了事後排查,審計日誌本身就是一種威懾機制——當每一步操作都有記錄,操作的謹慎程度自然提升。
五、完整Python代碼實現
下面是完整的 SandboxSkill 基類實現,以及兩個繼承自它的具體Skill示例。
#!/usr/bin/env python3
"""
sandbox_skill.py - MCP安全防火牆
沙箱Skill基類:用於隔離AI執行危險Shell/文件系統操作的權限圍欄
"""
import os
import re
import time
import shlex
import logging
import subprocess
import resource
from pathlib import Path
from typing import List, Optional
from dataclasses import dataclass, field
audit_logger = logging.getLogger("sandbox.audit")
@dataclass
class SandboxPolicy:
"""沙箱策略配置"""
allowed_commands: List[str] = field(default_factory=list)
allowed_paths: List[str] = field(default_factory=list)
forbidden_patterns: List[str] = field(default_factory=lambda: [
r'rm\s+-rf', # 危險刪除
r'\.\.\/', # 路徑穿越
r';\s*rm', # 命令注入後跟刪除
r'\|\s*sh', # 管道到shell
r'\$\(.*\)', # 命令替換
r'`.*`', # 反引號命令替換
r'>\s*/dev/', # 寫入設備文件
r'chmod\s+777', # 危險權限修改
r'sudo', # sudo提權
r'/etc/passwd', # 敏感文件
r'/etc/shadow', # 敏感文件
])
timeout_seconds: int = 30
max_output_bytes: int = 1024 * 1024 # 1MB
max_memory_bytes: int = 256 * 1024 * 1024# 256MB
@dataclass
class ExecutionResult:
"""命令執行結果"""
success: bool
stdout: str = ""
stderr: str = ""
return_code: int = -1
blocked: bool = False
block_reason: str = ""
execution_time_ms: float = 0.0
class SandboxSkill:
"""
沙箱Skill基類
核心原則:
1. 白名單優先:未明確允許的,一律拒絕
2. 路徑歸一化:先解析真實路徑,再做邊界檢查
3. 確定性安全:安全檢查用代碼邏輯,不依賴AI判斷
4. 全程審計:每一步操作都留下記錄
"""
SKILL_NAME: str = "base_sandbox"
POLICY: SandboxPolicy = SandboxPolicy()
def __init__(self, session_id: str, user_id: str):
self.session_id = session_id
self.user_id = user_id
def _check_forbidden_patterns(self, command: str) -> Optional[str]:
"""檢查命令中是否包含危險模式"""
for pattern in self.POLICY.forbidden_patterns:
if re.search(pattern, command, re.IGNORECASE):
returnf"命令包含禁止模式: {pattern}"
returnNone
def _check_command_whitelist(self, command: str) -> Optional[str]:
"""檢查命令是否在白名單中"""
try:
parts = shlex.split(command)
except ValueError as e:
returnf"命令解析失敗(可能存在注入): {e}"
ifnot parts:
return"空命令被拒絕"
cmd_name = os.path.basename(parts[0])
if cmd_name notin self.POLICY.allowed_commands:
return (
f"命令 '{cmd_name}' 不在白名單中。"
f"允許的命令: {', '.join(self.POLICY.allowed_commands)}"
)
returnNone
def _check_path_boundary(self, path: str) -> Optional[str]:
"""
路徑邊界檢查(核心防線)
使用 os.path.realpath 解析真實路徑,防止 ../ 穿越和符號連結攻擊
"""
ifnot self.POLICY.allowed_paths:
return"沒有配置允許的路徑,操作被拒絕"
real_path = os.path.realpath(path)
for allowed_root in self.POLICY.allowed_paths:
real_root = os.path.realpath(allowed_root)
try:
common = os.path.commonpath([real_path, real_root])
if common == real_root:
returnNone# 檢查通過
except ValueError:
continue
return (
f"路徑 '{path}'(真實路徑: '{real_path}')"
f"超出允許範圍: {self.POLICY.allowed_paths}"
)
def _set_resource_limits(self):
"""設置進程資源限制(在子進程中調用)"""
resource.setrlimit(
resource.RLIMIT_AS,
(self.POLICY.max_memory_bytes, self.POLICY.max_memory_bytes)
)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
def safe_execute(self, command: str, cwd: Optional[str] = None) -> ExecutionResult:
"""
安全執行命令的統一入口
執行流程:
1. 危險模式檢查(快速失敗)
2. 命令白名單檢查
3. 工作目錄路徑檢查
4. 執行命令,附加資源限制和超時控制
5. 審計日誌寫入
"""
start_time = time.monotonic()
# 三道安全檢查
checks = [
self._check_forbidden_patterns(command),
self._check_command_whitelist(command),
self._check_path_boundary(cwd) if cwd elseNone,
]
for reason in checks:
if reason:
result = ExecutionResult(
success=False, blocked=True, block_reason=reason
)
audit_logger.warning(
f"[{self.SKILL_NAME}] BLOCKED | {reason} | user={self.user_id}"
)
return result
# 構建最小化安全環境變量
safe_env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"}
try:
# ⚠️ 關鍵:shell=False + 列表參數,防止命令注入
proc = subprocess.run(
shlex.split(command),
shell=False, # 禁止 shell=True
capture_output=True,
text=True,
timeout=self.POLICY.timeout_seconds,
cwd=cwd,
env=safe_env,
preexec_fn=self._set_resource_limits,
)
elapsed = (time.monotonic() - start_time) * 1000
stdout = proc.stdout
if len(stdout.encode()) > self.POLICY.max_output_bytes:
stdout = stdout[:self.POLICY.max_output_bytes] + "\n[輸出已截斷]"
result = ExecutionResult(
success=proc.returncode == 0,
stdout=stdout,
stderr=proc.stderr[:4096],
return_code=proc.returncode,
execution_time_ms=elapsed
)
except subprocess.TimeoutExpired:
result = ExecutionResult(
success=False, blocked=True,
block_reason=f"執行超時(限制: {self.POLICY.timeout_seconds}秒)",
execution_time_ms=(time.monotonic() - start_time) * 1000
)
except Exception as e:
result = ExecutionResult(success=False, stderr=str(e))
audit_logger.info(
f"[{self.SKILL_NAME}] EXECUTE | "
f"blocked={result.blocked} | user={self.user_id} | "
f"time={result.execution_time_ms:.1f}ms"
)
return result
def safe_read_file(self, path: str, max_size_bytes: int = 102400) -> ExecutionResult:
"""安全讀取文件(內置路徑檢查)"""
block_reason = self._check_path_boundary(path)
if block_reason:
result = ExecutionResult(success=False, blocked=True, block_reason=block_reason)
audit_logger.warning(f"[{self.SKILL_NAME}] FILE_READ_BLOCKED | {block_reason}")
return result
try:
file_path = Path(os.path.realpath(path))
ifnot file_path.is_file():
return ExecutionResult(success=False, stderr=f"路徑不是文件: {path}")
size = file_path.stat().st_size
if size > max_size_bytes:
return ExecutionResult(
success=False, blocked=True,
block_reason=f"文件大小({size}字節)超過限制({max_size_bytes}字節)"
)
content = file_path.read_text(errors='replace')
return ExecutionResult(success=True, stdout=content)
except Exception as e:
return ExecutionResult(success=False, stderr=str(e))
# ══════════════════════════════════════════════════════════
# 具體Skill示例一:日誌分析(只讀,低風險)
# ══════════════════════════════════════════════════════════
class LogAnalysisSkill(SandboxSkill):
"""
日誌分析Skill
只允許讀取和搜索日誌文件,所有操作限制在 /data/logs 目錄下。
"""
SKILL_NAME = "log_analysis"
POLICY = SandboxPolicy(
allowed_commands=["grep", "cat", "tail", "head", "wc", "sort", "uniq"],
allowed_paths=["/data/logs", "/var/log/app"],
timeout_seconds=15,
max_output_bytes=512 * 1024,
)
def search_errors(
self, log_file: str, keyword: str, lines: int = 100
) -> ExecutionResult:
"""搜索日誌中的錯誤"""
# 額外檢查:關鍵詞不能包含注入字符
if re.search(r'[;|&`$(){}]', keyword):
return ExecutionResult(
success=False, blocked=True,
block_reason=f"關鍵詞包含危險字符: {keyword}"
)
# 先單獨做路徑檢查(grep本身不會做這件事)
path_check = self._check_path_boundary(log_file)
if path_check:
return ExecutionResult(success=False, blocked=True, block_reason=path_check)
command = f"grep -n {shlex.quote(keyword)} {shlex.quote(log_file)} | tail -{lines}"
return self.safe_execute(command, cwd="/data/logs")
def get_recent_logs(self, log_file: str, n: int = 50) -> ExecutionResult:
"""獲取最新N行日誌"""
if n > 1000:
n = 1000
return self.safe_execute(f"tail -{n} {shlex.quote(log_file)}")
# ══════════════════════════════════════════════════════════
# 具體Skill示例二:臨時文件清理(高危場景)
# ══════════════════════════════════════════════════════════
class TempCleanerSkill(SandboxSkill):
"""
臨時文件清理Skill
只允許刪除 /data/tmp 目錄下的 .log/.tmp/.bak 後綴文件。
注意:rm 不在默認白名單,刪除操作使用獨立的嚴格策略。
"""
SKILL_NAME = "temp_cleaner"
POLICY = SandboxPolicy(
allowed_commands=["find", "ls"], # ⚠️ rm 不在這裏!
allowed_paths=["/data/tmp"],
timeout_seconds=10,
)
# 刪除專用策略(更嚴格)
_DELETE_POLICY = SandboxPolicy(
allowed_commands=["rm"],
allowed_paths=["/data/tmp"],
forbidden_patterns=[
r'rm\s+-rf', # 禁止 -rf
r'rm.*\*', # 禁止通配符
r'\.\.', # 禁止路徑穿越
],
timeout_seconds=30,
)
def list_old_files(self, days: int = 3) -> ExecutionResult:
"""列出N天前的文件(預覽,不刪除)"""
ifnot1 <= days <= 30:
return ExecutionResult(
success=False, blocked=True,
block_reason=f"days參數超出範圍(1-30): {days}"
)
return self.safe_execute(
f"find /data/tmp -maxdepth 2 -mtime +{days} "
f"-name '*.log' -o -name '*.tmp'"
)
def delete_file(self, file_path: str) -> ExecutionResult:
"""
刪除單個文件(嚴格限制)
只允許刪除單個、明確命名的文件,
不允許通配符,不允許遞歸,不允許目錄刪除。
"""
# 只允許特定後綴
allowed_extensions = {'.log', '.tmp', '.bak'}
ext = Path(file_path).suffix.lower()
if ext notin allowed_extensions:
return ExecutionResult(
success=False, blocked=True,
block_reason=f"不允許刪除 '{ext}' 類型的文件,允許: {allowed_extensions}"
)
# 路徑邊界檢查
path_check = self._check_path_boundary(file_path)
if path_check:
return ExecutionResult(success=False, blocked=True, block_reason=path_check)
# 切換到嚴格的刪除策略
original_policy = self.POLICY
self.POLICY = self._DELETE_POLICY
result = self.safe_execute(
f"rm {shlex.quote(os.path.realpath(file_path))}"
)
self.POLICY = original_policy
return result
六、subprocess 安全用法 vs 不安全用法對比
這是一個值得專門拿出來講的技術細節,因為很多工程師知道"shell=True 不安全",但不知道為什麼,也不清楚其他陷阱在哪裏。
❌ 不安全 #1:shell=True 傳入字符串
# 危險!如果 user_input = "test.log; rm -rf /"
user_input = get_from_ai()
subprocess.run(f"cat {user_input}", shell=True) # 命令注入!
shell=True 會把整個字符串傳給 /bin/sh -c 執行,; 後面的命令會被當作新命令執行。這是命令注入的經典入口。
✅ 安全 #1:shell=False + 列表參數
# 安全:每個參數都是獨立的字符串,不經過shell解析
file_path = get_from_ai()
subprocess.run(["cat", file_path], shell=False)
# 即使 file_path = "test.log; rm -rf /",也只會嘗試打開字面文件名
❌ 不安全 #2:os.system()
# os.system 內部就是 shell=True,永遠不要用這個
os.system(f"grep {keyword} {logfile}") # 危險!
❌ 不安全 #3:忽略超時
# 沒有 timeout,AI可能觸發一個永久阻塞的命令
subprocess.run(["find", "/", "-name", "*.log"]) # 可能跑幾分鐘
✅ 安全 #3:始終設置超時 + 捕獲異常
try:
result = subprocess.run(
["find", "/data/logs", "-name", "*.log"],
shell=False, capture_output=True,
text=True, timeout=10,
)
except subprocess.TimeoutExpired:
logger.warning("命令執行超時,已終止")
❌ 不安全 #4:信任AI構造的路徑(最容易忽略)
path = ai_response["path"] # 可能是 "/data/logs/../../etc"
subprocess.run(["rm", "-rf", path], shell=False)
# shell=False 防了注入,但沒防路徑穿越!
✅ 安全 #4:先解析路徑,再做邊界檢查
def safe_path_check(user_path: str, allowed_root: str) -> str:
"""返回安全的真實路徑,如果越界則拋出異常"""
real_path = os.path.realpath(user_path) # 解析所有 .. 和軟連結
real_root = os.path.realpath(allowed_root)
if not real_path.startswith(real_root + os.sep) and real_path != real_root:
raise PermissionError(f"路徑越界: {real_path} 不在 {real_root} 下")
return real_path
try:
safe_target = safe_path_check(ai_path, "/data/logs")
subprocess.run(["rm", safe_target], shell=False, timeout=5)
except PermissionError as e:
logger.error(f"被安全檢查攔截: {e}")
七、部署沙箱Skill的五條工程實踐
代碼之外,還有幾個工程實踐建議,是從真實事故中總結出來的。
原則一:最小權限原則
每個Skill只申請它完成任務所需的最小權限。日誌分析Skill不需要寫權限,就不給寫權限。數據庫查詢Skill只需要SELECT,就不給DDL權限。
這個原則聽起來簡單,但在實際實現中容易被"方便性"侵蝕。工程師為了省事,常常給Skill配置一個"夠用就行"的寬泛權限。要抵制這種衝動。
原則二:默認拒絕,顯式允許
白名單的設計哲學:所有未明確允許的,一律拒絕。
這和很多人習慣的黑名單思維(列出危險的,其他的都允許)正好相反。黑名單有一個致命缺陷:你永遠無法窮舉所有危險情況。但白名單的上限是確定的——你只允許你理解的操作。
原則三:審計日誌必須獨立存儲
審計日誌本身也是攻擊目標。如果AI能操作日誌目錄,它就可以銷燬證據。審計日誌應該寫入獨立的、AI無權訪問的目錄,最好是追加寫入(append-only)的存儲。
原則四:漸進式信任,二步確認
對於高危操作(如刪除文件、修改配置),建議實現兩步確認機制:
AI先執行 dry-run,輸出將要執行的具體操作列表人類審查後,發出二次確認信號 Skill才執行真實操作
完全自動化並不總是最優解。
原則五:定期回顧審計日誌
審計日誌不是寫完就放着的。建議每週回顧一次被攔截的操作記錄,分析:
哪些合法操作被誤攔截了(需要調整白名單) 哪些攔截揭示了AI的異常行為模式 是否有重複嘗試繞過限制的跡象
這個回顧過程本身就是一種持續的安全運營。
八、一個完整的安全閉環
把今天講的內容串起來,一個完整的MCP安全閉環應該是這樣的:
用戶指令
│
▼
[AI模型] ──生成工具調用──▶ [MCP協議層]
│
▼
[SandboxSkill 入口]
│
┌─────────────┼─────────────┐
▼ ▼ ▼
[危險模式檢查] [白名單檢查] [路徑邊界檢查]
│ │ │
└──── 任一失敗 ────▶ [攔截 + 審計日誌]
│
全部通過
│
▼
[資源限制包裝器]
│
▼
[實際系統調用]
│
┌─────────────┴─────────────┐
▼ ▼
[成功結果] [超時/失敗]
│ │
└──────── [審計日誌] ────────┘
│
▼
[返回AI模型]
這個閉環中,沒有任何一個環節依賴"AI判斷這個操作是否安全"。每一道檢查都是確定性的代碼邏輯,要麼通過,要麼拒絕,沒有"也許"。
這就是安全工程和AI判斷的根本區別:安全工程用確定性替代概率性。
九、寫在最後:恐懼不是目的,控制才是
讀到這裏,你可能會覺得:MCP這麼危險,是不是乾脆別用了?
完全相反。
MCP的價值是真實的,它讓AI真正變成了可以幹活的工程夥伴。問題不在於"用不用",而在於"怎麼用"。
我們開頭講的那個事故,根本原因不是AI太危險,而是工程師沒有在AI和系統之間建立應有的隔離層。如果那套運維助手使用了本文描述的沙箱機制——路徑邊界檢查會在 realpath 解析後發現路徑超出了 /data/tmp 的邊界,命令會在執行前被攔截,審計日誌會記錄這次異常,工程師會收到警報。
11小時的恢復工作,可以被一個30行的路徑檢查函數避免。
安全不是AI的敵人,而是讓AI變得可信任的前提。沙箱Skill不是在限制AI,而是在給AI的能力畫一個清晰的邊界——讓它在這個邊界內盡情發揮,邊界之外由我們來守護。
這才是把AI用好的正確姿勢。
📌 本文要點速查
allowed_commands | ||
os.path.realpathcommonpath | ||
resource.setrlimittimeout | ||
shell=False |
“安全的本質是確定性AI的判斷是概率性的(99.9%的正確率意味着大規模調用時必然出錯)。 安全機制必須是確定性的——要麼通過,要麼拒絕,沒有「大概安全」。
我是專注 AI Agent深度實踐的努力撞蘑菇AI,致力於分享真實可用的 AI 使用經驗與工作流探索,歡迎你和我一起把 AI 玩明白,如果內容對你有幫助,歡迎關注、點贊、轉發。