feat: add command blacklist guardrail for MCP command channels
MCP 工具一旦被外部 agent 放行,参数里的命令就不再受它自身权限系统检查 (Claude Code 的 MCP 权限规则只按工具名匹配、不带参数内容), vps_exec 里的 rm -rf / 在 agent 侧零提示。这是唯一的兜底。 - src/guard.rs:名单判定。归一化(剥引号、\/ 还原、折叠空白、转小写、 短选项聚簇排序,故 -rf/-fr/-r -f 等价)+ * 通配 + 词边界匹配 + 截断标记检查 - data/deny-list.txt:首启写出内置 25 条不可逆灾难,用户可增删,每次判定重读 - 挂在 vps_exec 与 vps_terminal 的 send 上,命中即拒绝执行、不产生输出 - 工具描述写明护栏存在,并告诉 agent 被拦时不要换写法重试 - GET/PUT /api/guard + 设置页 MCP 标签下的名单编辑区 - 没有白名单、确认流程、风险分级与安全挡位 边界:防手滑,不防蓄意绕过(r\m -rf /、$(echo rm) -rf / 这类纯模式匹配必然漏)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
14e7066db9
commit
7bf552952f
6 files changed
+545
-5
No files matched your search
+5
-3
@@ -89,9 +89,11 @@
|
||||
- 执行命令(同步等待或转后台任务,含 Windows PowerShell 编码与 CLIXML 处理)→ 复用 **板块一/二(SSH & 本机执行链路)**
|
||||
- 列目录 / 读写改删文件 → 复用 **板块三(文件浏览器内核)**
|
||||
- 上传 / 下载大文件 → 复用 **板块四(文件上传下载引擎)**,一次调用完成传输
|
||||
- **命令安全护栏与审批**:
|
||||
- 针对 Agent 执行路径设置 TOML 规则护栏(红线 > 黑名单 > critical > 需审批 > 白名单 > 放行),每台主机可设独立安全挡位(`safe` / `standard` / `trusted`)。
|
||||
- 拦截时返回清晰的 `why` + `suggestion`;需人工确认的命令进入 UI 审批队列,并在主机运行记录与审计中留痕。
|
||||
- **命令黑名单护栏**:
|
||||
- 一份全局名单(`data/deny-list.txt`,一行一条、`*` 通配、按词边界,首启写出内置的不可逆灾难,之后用户自己增删),只覆盖 `vps_exec` 与 `vps_terminal` 的命令通道;文件写/传输类不判定。
|
||||
- 命中即拒绝执行并说明命中了哪条;没有白名单、确认流程、风险分级与安全挡位。
|
||||
- 存在的理由只有一个:MCP 工具一旦被外部 agent 放行,参数里的命令就不再受它自身权限系统检查,这里是唯一的兜底。
|
||||
- 边界:防手滑,不防蓄意绕过(`r\m -rf /`、`$(echo rm) -rf /` 这类形态纯模式匹配必然漏)。
|
||||
- **双传输支持**:`--mcp-stdio`(供本机 Claude Code 零配置直连)+ Streamable HTTP(带范围令牌)。
|
||||
|
||||
---
|
||||
|
||||
@@ -100,6 +100,11 @@ const hasAdminPassword = ref(false);
|
||||
const savingAuth = ref(false);
|
||||
const savingMcp = ref(false);
|
||||
|
||||
// 命令黑名单(Agent 执行护栏):名单原文与文件路径
|
||||
const guardContent = ref('');
|
||||
const guardPath = ref('');
|
||||
const savingGuard = ref(false);
|
||||
|
||||
const mcpStatus = ref<{
|
||||
exePath: string;
|
||||
port: number;
|
||||
@@ -368,6 +373,41 @@ async function loadSettings() {
|
||||
}
|
||||
void loadAutostartSettings();
|
||||
void loadAboutInfo();
|
||||
void loadGuardList();
|
||||
}
|
||||
|
||||
async function loadGuardList() {
|
||||
try {
|
||||
const res = await fetch('/api/guard');
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
guardContent.value = data.content || '';
|
||||
guardPath.value = data.path || '';
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGuardList() {
|
||||
savingGuard.value = true;
|
||||
try {
|
||||
const res = await fetch('/api/guard', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: guardContent.value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
showToast(data.error || '保存命令黑名单失败', 'err');
|
||||
return;
|
||||
}
|
||||
showToast('命令黑名单已保存,立即生效');
|
||||
} catch (e) {
|
||||
showToast(`保存失败: ${(e as Error).message}`, 'err');
|
||||
} finally {
|
||||
savingGuard.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAutostartSettings() {
|
||||
@@ -1965,6 +2005,51 @@ onBeforeUnmount(() => {
|
||||
>{{ displayedMcpConfigJson }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 板块 2:命令黑名单(Agent 执行护栏) -->
|
||||
<section
|
||||
class="space-y-4 rounded-2xl border border-zinc-200/80 bg-white/85 p-5 shadow-[0_2px_12px_rgba(0,0,0,0.02)] backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/85"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 border-b border-zinc-100 pb-3.5 dark:border-zinc-800">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">命令黑名单(Agent 执行护栏)</h3>
|
||||
<p class="mt-0.5 text-xs text-zinc-400">
|
||||
命中即拒绝执行,agent 拿不到任何执行结果。外部 agent 一旦放行 MCP 工具,参数里的命令就不再受它自身权限系统检查,这里是唯一的兜底
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-lg bg-zinc-900 px-3 py-1 text-[11px] font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
|
||||
:disabled="savingGuard"
|
||||
@click="saveGuardList"
|
||||
>
|
||||
{{ savingGuard ? '保存中…' : '保存名单' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-xl border border-zinc-200/70 bg-zinc-50/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-800/40">
|
||||
<span class="shrink-0 text-[11px] font-medium text-zinc-500">名单文件</span>
|
||||
<span class="flex-1 truncate font-mono text-[11px] text-zinc-700 dark:text-zinc-300">{{ guardPath || 'data/deny-list.txt' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-[11px] text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200"
|
||||
@click="copyText(guardPath, '已复制名单文件路径')"
|
||||
>
|
||||
复制路径
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="guardContent"
|
||||
spellcheck="false"
|
||||
rows="16"
|
||||
class="w-full resize-y rounded-xl border border-zinc-200/80 bg-white p-3.5 font-mono text-[11px] leading-relaxed text-zinc-800 outline-none focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200"
|
||||
></textarea>
|
||||
|
||||
<p class="text-[11px] leading-relaxed text-zinc-400">
|
||||
一行一条,<code class="font-mono">#</code> 起头是注释。<code class="font-mono">*</code> 通配,没有 <code class="font-mono">*</code> 就是精确匹配,按词边界匹配(<code class="font-mono">rm -rf /</code> 不会误伤 <code class="font-mono">rm -rf /var/log</code>)。判定前会剥引号、转小写,并把 <code class="font-mono">-r -f</code> 与 <code class="font-mono">-fr</code> 视作等价。这份名单防手滑,不防蓄意绕过。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 分类 2:访问安全 (Security) -->
|
||||
|
||||
+36
@@ -44,6 +44,7 @@ use uuid::Uuid;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub guard: Arc<crate::guard::Guard>,
|
||||
pub ssh_pool: Arc<SshPool>,
|
||||
pub terminals: Arc<TerminalManager>,
|
||||
pub transfers: Arc<TransferManager>,
|
||||
@@ -62,6 +63,7 @@ pub fn build_api_router(state: Arc<AppState>) -> Router {
|
||||
.route("/api/settings", get(api_get_settings))
|
||||
.route("/api/settings/auth", put(api_update_auth_settings))
|
||||
.route("/api/settings/mcp", put(api_update_mcp_settings))
|
||||
.route("/api/guard", get(api_get_guard).put(api_update_guard))
|
||||
.route("/api/events", get(api_sse_events))
|
||||
.route("/api/hosts", get(list_hosts).post(create_host))
|
||||
.route("/api/hosts/reorder", put(reorder_hosts))
|
||||
@@ -1437,6 +1439,40 @@ async fn api_update_mcp_settings(
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_get_guard(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"path": state.guard.path().to_string_lossy(),
|
||||
"content": state.guard.read_list(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GuardUpdatePayload {
|
||||
content: String,
|
||||
}
|
||||
|
||||
async fn api_update_guard(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(p): Json<GuardUpdatePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match state.guard.write_list(&p.content) {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"path": state.guard.path().to_string_lossy(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => err_response(StatusCode::BAD_REQUEST, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_mcp_api_key(headers: &HeaderMap) -> Option<String> {
|
||||
if let Some(auth) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) {
|
||||
if let Some(k) = auth.strip_prefix("Bearer ") {
|
||||
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
//! 命令黑名单护栏。
|
||||
//!
|
||||
//! 只做一件事:MCP 的 agent 通道里,命中黑名单的命令**拒绝执行**。
|
||||
//! 没有白名单、没有确认流程、没有风险分级、没有安全挡位——那些要么得靠 agent 自觉,
|
||||
//! 要么是另一套系统。这里就是一份名单。
|
||||
//!
|
||||
//! 为什么需要:MCP 工具一旦被外部 agent 放行,参数里的命令字符串就不再被 agent 自身的
|
||||
//! 权限系统检查(Claude Code 的 MCP 权限规则只按工具名匹配、不带参数内容)。所以
|
||||
//! `vps_exec(host, "rm -rf /")` 在 agent 侧零提示,而同一条命令敲进它自己的 Bash 工具会被拦。
|
||||
//!
|
||||
//! 边界:**这份名单防手滑,不防蓄意绕过**。`r\m -rf /`、`$(echo rm) -rf /` 这类形态
|
||||
//! 纯模式匹配必然漏——要堵住得把命令 tokenize 成 argv 再解命令替换,是另一个量级的东西。
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 名单文件名,落在数据目录(与 vps-switch.db 同级)。
|
||||
pub const DENY_LIST_FILE: &str = "deny-list.txt";
|
||||
|
||||
/// 首启写出的默认名单。用户可直接编辑该文件增删,改完立即生效(每次判定都重读)。
|
||||
const DEFAULT_DENY_LIST: &str = r#"# vps-switch 命令黑名单
|
||||
#
|
||||
# 命中即拒绝执行,agent 拿不到任何执行结果。这里只放不可逆的灾难,正常运维不该出现。
|
||||
#
|
||||
# 写法同 Claude Code 的 Bash(...) 规则:
|
||||
# - 一行一条,字面文本 + * 通配(* 可放任意位置);没有 * 就是精确匹配
|
||||
# - 按词边界匹配,所以 `rm -rf /` 不会误伤 `rm -rf /var/log`
|
||||
# - 匹配前统一归一化:剥引号、\/ 还原成 /、折叠空白、转小写、短选项排序
|
||||
# (故 -rf / -fr / -r -f / -f -r 四种写法等价,rm -rf "/" 也拦得住)
|
||||
#
|
||||
# 删掉任何一条即失去那条保护;在下面按同样写法加自己的。
|
||||
# 这份名单防手滑,不防蓄意绕过(r\m -rf /、$(echo rm) -rf / 这类必然漏)。
|
||||
|
||||
# ── 删根与系统目录(递归删除不可逆)────────────────────────────
|
||||
rm -rf /
|
||||
rm -rf /etc
|
||||
rm -rf /usr
|
||||
rm -rf /bin
|
||||
rm -rf /sbin
|
||||
rm -rf /lib
|
||||
rm -rf /lib64
|
||||
rm -rf /boot
|
||||
rm -rf /dev
|
||||
rm -rf /proc
|
||||
rm -rf /sys
|
||||
rm -rf /root
|
||||
rm --no-preserve-root
|
||||
|
||||
# ── 格式化与向块设备裸写 ───────────────────────────────────────
|
||||
# mkfs 后面必然跟设备路径,写成「mkfs…/dev/…」才不会误伤 which mkfs / man mkfs 这类提及
|
||||
mkfs*/dev/*
|
||||
dd*of=/dev/*
|
||||
|
||||
# ── fork 炸弹 ──────────────────────────────────────────────────
|
||||
:|:&
|
||||
|
||||
# ── Windows:递归删盘根或系统目录 ──────────────────────────────
|
||||
Remove-Item -Recurse -Force C:\
|
||||
Remove-Item -Recurse -Force C:\Windows
|
||||
rd /s /q C:\
|
||||
rd /s /q C:\Windows
|
||||
|
||||
# ── Windows:格式化 / 抹盘 / 毁引导 / 删卷影副本 ───────────────
|
||||
format C:
|
||||
Format-Volume
|
||||
Clear-Disk
|
||||
diskpart clean
|
||||
diskpart /s
|
||||
reg delete HKLM
|
||||
bcdedit /delete
|
||||
vssadmin delete shadows
|
||||
"#;
|
||||
|
||||
/// 判定结果。只有两种:放行,或拒绝并说明命中了哪条。
|
||||
pub enum Verdict {
|
||||
Allow,
|
||||
Deny { why: String },
|
||||
}
|
||||
|
||||
pub struct Guard {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Guard {
|
||||
/// 打开名单;文件不存在则写出默认名单。名单路径固定,用户改的就是这个文件。
|
||||
pub fn load_or_create(data_dir: &Path) -> Result<Self> {
|
||||
std::fs::create_dir_all(data_dir)?;
|
||||
let path = data_dir.join(DENY_LIST_FILE);
|
||||
if !path.exists() {
|
||||
std::fs::write(&path, DEFAULT_DENY_LIST)
|
||||
.with_context(|| format!("写出默认命令黑名单失败: {}", path.display()))?;
|
||||
}
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 名单原文(供界面展示与编辑)。
|
||||
pub fn read_list(&self) -> String {
|
||||
std::fs::read_to_string(&self.path).unwrap_or_else(|_| DEFAULT_DENY_LIST.to_string())
|
||||
}
|
||||
|
||||
/// 覆盖写名单。内容不做校验——用户想拦什么就拦什么;写坏了下次判定退回内置默认。
|
||||
pub fn write_list(&self, content: &str) -> Result<()> {
|
||||
std::fs::write(&self.path, content)
|
||||
.with_context(|| format!("写入命令黑名单失败: {}", self.path.display()))
|
||||
}
|
||||
|
||||
/// 判定一条命令。每次重读名单文件,所以用户改完立刻生效。
|
||||
pub fn check(&self, command: &str) -> Verdict {
|
||||
let raw = command.trim();
|
||||
if raw.is_empty() {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
|
||||
// 命令被摘要截断过就拒绝:`…` / `[truncated]` 说明 agent 手里已经不是完整命令,
|
||||
// 匹配它没有意义(真命令未知),放行则等于无护栏。
|
||||
if raw.contains('\u{2026}') || raw.to_lowercase().contains("[truncated") {
|
||||
return Verdict::Deny {
|
||||
why: "命令疑似被截断(含 … 或 [truncated]),无法确认完整命令,已拒绝执行。请重新给出完整命令。"
|
||||
.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let hay = normalize(raw);
|
||||
if hay.is_empty() {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
|
||||
for pattern in self.patterns() {
|
||||
if pattern_matches(&pattern, &hay) {
|
||||
return Verdict::Deny {
|
||||
why: format!(
|
||||
"命中命令黑名单「{}」。该规则在 {},确认要放开请自行编辑该文件。",
|
||||
pattern,
|
||||
self.path.display()
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
Verdict::Allow
|
||||
}
|
||||
|
||||
/// 读名单。读不到就退回内置默认——护栏宁可多拦,不能因为文件没了就静默失效。
|
||||
fn patterns(&self) -> Vec<String> {
|
||||
parse_patterns(&self.read_list())
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析名单:一行一条,`#` 起头与空行跳过。
|
||||
fn parse_patterns(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(normalize)
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 归一化:剥引号 → `\/` 还原成 `/` → 转小写 → 折叠空白 → 短选项聚簇排序。
|
||||
/// 名单里的模式与待判命令走同一条归一化,所以两边写法天然对齐。
|
||||
fn normalize(text: &str) -> String {
|
||||
let no_quotes: String = text.chars().filter(|c| *c != '"' && *c != '\'').collect();
|
||||
let unescaped = no_quotes.replace("\\/", "/");
|
||||
let lowered = unescaped.to_lowercase();
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut pending: Vec<char> = Vec::new();
|
||||
for token in lowered.split_whitespace() {
|
||||
if is_short_flag_cluster(token) {
|
||||
// `-rf` / `-fr` / `-r -f` / `-f -r` 全部塌成同一个 `-fr`
|
||||
pending.extend(token[1..].chars());
|
||||
} else {
|
||||
flush_flags(&mut pending, &mut out);
|
||||
out.push(token.to_string());
|
||||
}
|
||||
}
|
||||
flush_flags(&mut pending, &mut out);
|
||||
out.join(" ")
|
||||
}
|
||||
|
||||
/// 短选项聚簇:`-` 后 1~4 个纯字母。`--recursive`、`-Recurse` 这类长旗标不动它。
|
||||
fn is_short_flag_cluster(token: &str) -> bool {
|
||||
let Some(body) = token.strip_prefix('-') else {
|
||||
return false;
|
||||
};
|
||||
if body.is_empty() || body.starts_with('-') {
|
||||
return false;
|
||||
}
|
||||
body.len() <= 4 && body.chars().all(|c| c.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
fn flush_flags(pending: &mut Vec<char>, out: &mut Vec<String>) {
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
pending.sort_unstable();
|
||||
pending.dedup();
|
||||
out.push(format!("-{}", pending.iter().collect::<String>()));
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// 模式匹配:`*` 通配,无 `*` 即精确匹配;首尾按词边界卡住(模式自带首尾 `*` 则放开那一侧)。
|
||||
/// 词边界只认 [A-Za-z0-9_],所以 `/`、空格、`;`、`|`、`&`、`.`、`\` 都算边界——
|
||||
/// 这正是 `rm -rf /` 不误伤 `rm -rf /var/log`、`mkfs` 不误伤 `mkfsomething` 的原因。
|
||||
fn pattern_matches(pattern: &str, hay: &str) -> bool {
|
||||
if pattern.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let head_anchored = !pattern.starts_with('*');
|
||||
let tail_anchored = !pattern.ends_with('*');
|
||||
let parts: Vec<&str> = pattern.split('*').filter(|p| !p.is_empty()).collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
// 模式就是 "*":命中任何非空命令
|
||||
return !hay.is_empty();
|
||||
}
|
||||
|
||||
for (start, _) in hay.match_indices(parts[0]) {
|
||||
if head_anchored
|
||||
&& start != 0
|
||||
&& hay[..start].chars().next_back().map(is_word_char).unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut pos = start + parts[0].len();
|
||||
let mut ok = true;
|
||||
for part in &parts[1..] {
|
||||
match hay[pos..].find(part) {
|
||||
Some(offset) => pos += offset + part.len(),
|
||||
None => {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
if tail_anchored
|
||||
&& pos != hay.len()
|
||||
&& hay[pos..].chars().next().map(is_word_char).unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn guard() -> Guard {
|
||||
Guard {
|
||||
path: PathBuf::from("does-not-exist-deny-list.txt"),
|
||||
}
|
||||
}
|
||||
|
||||
fn denied(cmd: &str) -> bool {
|
||||
matches!(guard().check(cmd), Verdict::Deny { .. })
|
||||
}
|
||||
|
||||
fn allowed(cmd: &str) -> bool {
|
||||
matches!(guard().check(cmd), Verdict::Allow)
|
||||
}
|
||||
|
||||
/// 必须拦:不可逆灾难。含 1Shell 2026-07-04 云电脑事故那条命令。
|
||||
#[test]
|
||||
fn denies_catastrophic() {
|
||||
for cmd in [
|
||||
// POSIX
|
||||
"rm -rf /",
|
||||
"rm -rf /*",
|
||||
"rm --no-preserve-root -rf /",
|
||||
"sudo rm -rf /",
|
||||
"rm -rf /etc",
|
||||
"rm -rf /usr",
|
||||
"rm -rf /boot",
|
||||
"mkfs.ext4 /dev/sda1",
|
||||
"mkfs -t ext4 /dev/nvme0n1",
|
||||
"dd if=/dev/zero of=/dev/sda bs=1M",
|
||||
"dd bs=1M of=/dev/nvme0n1",
|
||||
":(){ :|:& };:",
|
||||
":(){:|:&};:",
|
||||
// Windows
|
||||
"Remove-Item -Recurse -Force C:\\",
|
||||
"Remove-Item -Recurse -Force C:\\Windows",
|
||||
"rd /s /q C:\\",
|
||||
"rd /s /q C:\\Windows",
|
||||
"format C: /y",
|
||||
"Format-Volume -DriveLetter C -Confirm:$false",
|
||||
"Clear-Disk -Number 0 -RemoveData -Confirm:$false",
|
||||
"diskpart /s wipe.txt",
|
||||
"reg delete HKLM\\SOFTWARE /f",
|
||||
"bcdedit /delete {default} /f",
|
||||
"vssadmin delete shadows /all /quiet",
|
||||
] {
|
||||
assert!(denied(cmd), "应拦截但放行了: {cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 必须拦住手滑写法的变体:引号、反斜杠转义、大小写、短选项顺序与拆写。
|
||||
#[test]
|
||||
fn denies_sloppy_spellings() {
|
||||
for cmd in [
|
||||
"rm -rf \"/\"",
|
||||
"rm -rf '/'",
|
||||
"rm -rf \\/",
|
||||
"RM -RF /",
|
||||
"rm -fr /",
|
||||
"rm -r -f /",
|
||||
"rm -f -r /",
|
||||
"rm -rf /",
|
||||
"sudo -u root rm -rf /",
|
||||
"/usr/bin/rm -rf /",
|
||||
"bash -c \"rm -rf /\"",
|
||||
] {
|
||||
assert!(denied(cmd), "应拦截但放行了: {cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 必须放行:日常运维。误拦了这几条,这个功能就不该存在。
|
||||
#[test]
|
||||
fn allows_ordinary_ops() {
|
||||
for cmd in [
|
||||
"ls -la",
|
||||
"cat /etc/nginx/nginx.conf",
|
||||
"systemctl status nginx",
|
||||
"docker compose up -d",
|
||||
"docker ps -a --format '{{.Names}}'",
|
||||
"df -h",
|
||||
"free -m",
|
||||
"tail -n 100 /var/log/auth.log",
|
||||
"grep -r nginx /etc/nginx",
|
||||
// 删子目录是正常清理,不是灾难
|
||||
"rm -rf /var/log/nginx.old",
|
||||
"rm -rf /tmp/build",
|
||||
"rm -rf /home/deploy/app/dist",
|
||||
"rm -rf ./node_modules",
|
||||
"rm -rf /opt/app/cache",
|
||||
// 读块设备不是写块设备
|
||||
"dd if=/dev/sda of=/root/backup.img bs=4M",
|
||||
"dd if=/dev/zero of=/tmp/zero.bin bs=1M count=10",
|
||||
"mkfs_report=$(which mkfs)",
|
||||
"which mkfs",
|
||||
"man mkfs",
|
||||
"lsblk",
|
||||
"apt-get install -y nginx",
|
||||
"ufw allow 443",
|
||||
// Windows 日常
|
||||
"Get-ChildItem C:\\Windows\\System32",
|
||||
"Get-Content C:\\ProgramData\\ssh\\sshd_config",
|
||||
"reg query HKLM\\SOFTWARE\\OpenSSH",
|
||||
"Remove-Item C:\\temp\\one-file.txt",
|
||||
"Remove-Item -Recurse D:\\build\\out",
|
||||
"New-Item -ItemType Directory C:\\temp\\work",
|
||||
"Get-Service sshd",
|
||||
"Restart-Service sshd",
|
||||
"systeminfo",
|
||||
"git log --format=%H -1",
|
||||
"docker system prune -af",
|
||||
] {
|
||||
assert!(allowed(cmd), "不应拦截但拦了: {cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 边界:模式两侧的词边界必须成立,短模式不能吃掉长命令。
|
||||
#[test]
|
||||
fn word_boundaries_hold() {
|
||||
assert!(allowed("rm -rf /var/log/nginx.old"));
|
||||
assert!(allowed("lsblk"));
|
||||
assert!(allowed("mkfsomething --help"));
|
||||
assert!(allowed("reg query HKLM\\SOFTWARE"));
|
||||
assert!(allowed("docker run --rm alpine echo hi"));
|
||||
assert!(allowed("format-json --in x"));
|
||||
assert!(allowed("git log --format=%H"));
|
||||
}
|
||||
|
||||
/// 截断标记:命令被摘要过就拒绝,不能拿半截命令去匹配。
|
||||
#[test]
|
||||
fn denies_truncated_command() {
|
||||
assert!(denied("rm -rf /very/long/path…"));
|
||||
assert!(denied("echo hello [truncated 120 chars]"));
|
||||
}
|
||||
|
||||
/// 名单文件解析:注释与空行跳过,用户增删立即生效。
|
||||
#[test]
|
||||
fn parses_user_list() {
|
||||
let list = "# 注释\n\nrm -rf /\n\n my-dangerous-cmd \n";
|
||||
let pats = parse_patterns(list);
|
||||
assert_eq!(pats, vec!["rm -fr /".to_string(), "my-dangerous-cmd".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod db;
|
||||
#[cfg(windows)]
|
||||
mod desktop;
|
||||
mod files;
|
||||
mod guard;
|
||||
mod inspector;
|
||||
mod mcp;
|
||||
mod models;
|
||||
@@ -27,6 +28,7 @@ use axum::{
|
||||
use crypto::MasterKey;
|
||||
use db::Database;
|
||||
use files::TransferManager;
|
||||
use guard::Guard;
|
||||
use inspector::InspectorEngine;
|
||||
use mcp::{run_stdio_bridge, sync_cli_mcp_configs, ExecTaskManager};
|
||||
use rust_embed::RustEmbed;
|
||||
@@ -194,6 +196,7 @@ fn main() -> Result<()> {
|
||||
let exec_tasks = Arc::new(ExecTaskManager::new());
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
guard: Arc::new(Guard::load_or_create(&data_dir)?),
|
||||
ssh_pool: Arc::new(SshPool::new()),
|
||||
terminals: Arc::new(TerminalManager::new()),
|
||||
transfers: Arc::new(TransferManager::new()),
|
||||
|
||||
+14
-2
@@ -2,6 +2,7 @@ use crate::{
|
||||
api::AppState,
|
||||
db::Database,
|
||||
files,
|
||||
guard::Verdict,
|
||||
models::{HostRecord, LOCAL_HOST_ID},
|
||||
ssh::open_host_channel,
|
||||
terminal::looks_like_interactive_prompt,
|
||||
@@ -624,7 +625,7 @@ pub fn mcp_tool_definitions() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "vps_exec",
|
||||
"description": "在一台或多台 VPS 上并发执行非交互式命令(直接传主机名如 'vip-hk')。若在 wait_seconds 内完成则同步返回 stdout/stderr/exitCode;若超时未完则自动转入后台并返回 taskId;若检测到命令卡在 [Y/n] 等交互提问超过 2 秒会自动早退提醒改用 vps_terminal。",
|
||||
"description": "在一台或多台 VPS 上并发执行非交互式命令(直接传主机名如 'vip-hk')。若在 wait_seconds 内完成则同步返回 stdout/stderr/exitCode;若超时未完则自动转入后台并返回 taskId;若检测到命令卡在 [Y/n] 等交互提问超过 2 秒会自动早退提醒改用 vps_terminal。命令会先过本机命令黑名单,命中不可逆灾难(如 rm -rf /、mkfs、格式化盘)即直接拒绝执行、不产生任何输出——名单文件是 data/deny-list.txt,被拦时不要换写法重试,直接告诉用户哪条被拦。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -651,7 +652,7 @@ pub fn mcp_tool_definitions() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "vps_terminal",
|
||||
"description": "有状态 PTY 交互式终端工具(基于 Rust alacritty_terminal 真实二维屏幕网格)。默认无头运行(reveal_ui: false),专门用于运行需要交互确认(如输入 yes/[Y/n]、选择安装菜单)的脚本——当脚本停下等待输入时会立即返回 state='waiting_input' 及光标所在行文字,你可以继续调用 send 发送 'y'。当遇到需要用户亲自输入隐私密码(如数据库密码、sudo密码、密钥口令)或人工接手时,设置 reveal_ui=true 或调用 action='reveal',即可像 Playwright 一样直接将终端弹窗展现在用户界面上。",
|
||||
"description": "有状态 PTY 交互式终端工具(基于 Rust alacritty_terminal 真实二维屏幕网格)。默认无头运行(reveal_ui: false),专门用于运行需要交互确认(如输入 yes/[Y/n]、选择安装菜单)的脚本——当脚本停下等待输入时会立即返回 state='waiting_input' 及光标所在行文字,你可以继续调用 send 发送 'y'。当遇到需要用户亲自输入隐私密码(如数据库密码、sudo密码、密钥口令)或人工接手时,设置 reveal_ui=true 或调用 action='reveal',即可像 Playwright 一样直接将终端弹窗展现在用户界面上。action='send' 的输入同样会先过命令黑名单,命中不可逆灾难即拒绝发送。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1058,6 +1059,12 @@ async fn tool_vps_exec(
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| anyhow!("command 参数不能为空"))?
|
||||
.to_string();
|
||||
|
||||
// 护栏:agent 通道的命令先过黑名单。命中即拒绝执行,不给任何执行结果。
|
||||
if let Verdict::Deny { why } = state.guard.check(&command) {
|
||||
return Err(anyhow!("{why}"));
|
||||
}
|
||||
|
||||
let wait_seconds = args
|
||||
.get("wait_seconds")
|
||||
.and_then(|v| v.as_u64())
|
||||
@@ -1201,6 +1208,11 @@ async fn tool_vps_terminal(state: &Arc<AppState>, args: Value) -> Result<Value>
|
||||
"reveal" | "read" => pane.read_screen_snapshot(max_lines),
|
||||
"send" => {
|
||||
let input = args.get("input").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// 护栏:终端也是命令通道,`vps_exec` 拦下的写法不能从这里绕过去。
|
||||
// 交互回答("y"、密码)不会命中黑名单,照常发送。
|
||||
if let Verdict::Deny { why } = state.guard.check(input) {
|
||||
return Err(anyhow!("{why}"));
|
||||
}
|
||||
let submit = args.get("submit").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let mut bytes = input.as_bytes().to_vec();
|
||||
if submit {
|
||||
|
||||
Reference in new issue
Block a user