AI Agent 技能越装越多,怎么安全给团队试?本地技能目录做成只读演示页,用 cpolar 短时验收
AI Agent 技能越装越多,怎么安全给团队试?本地技能目录做成只读演示页,用 cpolar 短时验收

团队开始共用 AI Agent 后,最容易被忽略的不是“技能好不好用”,而是“评审的人到底看到了什么”。直接把技能目录打包发群里,既容易夹带配置和密钥,也无法让产品、测试同事在手机上快速确认;把 Agent 控制台整个开放出去,风险更高。
我更推荐把验收和运行彻底分开:本机只读取整理好的技能元数据,生成一个没有写入按钮、没有命令入口的演示页,再用 cpolar 临时映射这个页面。评审人看到的是技能说明、权限清单、允许访问的路径和模拟结果,真正的 Agent、Shell 端口与密钥始终留在本机。
1 什么是“只读技能验收页”?
这里的“只读”不是给原有管理后台隐藏几个按钮,而是单独做一个极小的展示服务。它只接受 GET 请求,只扫描指定目录中的 SKILL.md 与人工填写的 manifest.json,不会调用技能,也不会执行目录里的脚本。
SKILL.md 负责提供名称和简介,manifest.json 记录评审结论:技能需要哪些权限、允许读取哪些路径,以及一次脱敏后的模拟输出。权限清单必须由维护者审核后填写,不能把它当成自动沙箱或安全扫描结果。
这种拆分有三个直接好处:
- 团队可以先判断“值不值得装”,不用接触真实运行环境;
- 页面没有上传、删除、编辑和 Shell API,验收范围很清楚;
- 分享结束后停掉 cpolar,临时入口随即结束,不必长期维护公网演示站。
提醒一句:不要把现用技能目录直接拿来演示。先复制或手工整理一份专用目录,只放公开说明和脱敏数据,.env、token、Cookie、运行日志、用户目录路径都不要放进去。
2 环境准备:创建独立的演示目录
本文使用 Python 3 标准库,不需要安装 Flask。先确认环境:
python3 --version
页面服务监听 127.0.0.1:8000。绑定回环地址是刻意为之:局域网设备不能绕过验收链接直接访问,公网入口只交给后面的 cpolar。
2.1 准备一条脱敏的技能样例
新建独立目录,不要把路径指向正在使用的 Agent 配置目录:
mkdir -p "$HOME/agent-skills-demo/code-review"
cat > "$HOME/agent-skills-demo/code-review/SKILL.md" <<'EOF'
---
name: code-review
summary: 检查代码变更并输出只读审查意见
---
EOF
cat > "$HOME/agent-skills-demo/code-review/manifest.json" <<'EOF'
{
"permissions": ["读取待审代码", "读取仓库差异"],
"allowed_paths": ["demo-repo/src", "demo-repo/tests"],
"simulation": "发现 2 项风格问题;未执行修改,未调用 Shell。"
}
EOF
这里别填真实仓库的绝对路径。演示页的目标是确认权限边界,不是把内部目录结构完整交出去;用项目代号或相对路径已经足够。
manifest.json 必须是合法 JSON。如果页面启动时报解析错误,先执行下面这条命令定位,而不是直接删字段:
python3 -m json.tool "$HOME/agent-skills-demo/code-review/manifest.json"

这张图应展示整理后的目录结构:每个技能只有 SKILL.md 和 manifest.json,不包含脚本、密钥文件与运行日志。看到的文件越少,验收边界越容易讲清楚。
3 编写只接受读取请求的演示服务
下面的服务做了几层收口:根目录由环境变量固定;页面字段经过 HTML 转义;只实现 /;POST、PUT、DELETE 一律返回 405;访问必须携带随机 token。它没有文件下载接口,也没有执行命令的路由。
cat > "$HOME/skill_demo_server.py" <<'PY'
#!/usr/bin/env python3
import html
import json
import os
import secrets
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
ROOT = Path(os.environ.get("SKILLS_ROOT", "~/agent-skills-demo")).expanduser().resolve()
TOKEN = os.environ.get("DEMO_TOKEN", "")
def load_cards():
cards = []
for skill_file in sorted(ROOT.glob("*/SKILL.md")):
skill_dir = skill_file.parent
manifest_file = skill_dir / "manifest.json"
if not manifest_file.is_file():
continue
name = skill_dir.name
summary = "未提供公开摘要"
for line in skill_file.read_text(encoding="utf-8").splitlines()[:30]:
if line.startswith("name:"):
name = line.split(":", 1)[1].strip()
elif line.startswith("summary:") or line.startswith("description:"):
summary = line.split(":", 1)[1].strip()
data = json.loads(manifest_file.read_text(encoding="utf-8"))
cards.append({
"name": name,
"summary": summary,
"permissions": data.get("permissions", []),
"allowed_paths": data.get("allowed_paths", []),
"simulation": data.get("simulation", "")
})
return cards
def list_html(items):
return "".join(f"<li>{html.escape(str(item))}</li>" for item in items)
def render_page():
sections = []
for card in load_cards():
sections.append(f"""
<article>
<h2>{html.escape(card['name'])}</h2>
<p>{html.escape(card['summary'])}</p>
<h3>所需权限</h3><ul>{list_html(card['permissions'])}</ul>
<h3>允许访问的路径</h3><ul>{list_html(card['allowed_paths'])}</ul>
<h3>模拟运行结果</h3><pre>{html.escape(card['simulation'])}</pre>
</article>""")
body = "".join(sections) or "<p>当前没有可展示的技能。</p>"
return f"""<!doctype html><html lang="zh-CN"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Agent Skills 只读验收</title>
<style>body{{max-width:900px;margin:40px auto;padding:0 18px;font:16px/1.7 system-ui;color:#172033}}
article{{border:1px solid #d9deea;border-radius:12px;padding:20px;margin:18px 0;background:#fafbff}}
h1,h2{{line-height:1.3}}pre{{white-space:pre-wrap;background:#eef2f8;padding:12px;border-radius:8px}}</style>
</head><body><h1>Agent Skills 只读验收</h1>
<p>本页仅展示人工审核后的元数据,不提供安装、执行、修改或下载能力。</p>{body}</body></html>""".encode("utf-8")
class Handler(BaseHTTPRequestHandler):
def send_headers(self, status, content_type="text/plain; charset=utf-8"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
self.send_header("Referrer-Policy", "no-referrer")
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
supplied = parse_qs(parsed.query).get("token", [""])[0]
if parsed.path != "/":
self.send_headers(404)
self.wfile.write(b"Not Found")
return
if not TOKEN or not secrets.compare_digest(supplied, TOKEN):
self.send_headers(403)
self.wfile.write(b"Forbidden")
return
try:
page = render_page()
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
self.send_headers(500)
self.wfile.write(f"Data error: {type(exc).__name__}".encode())
return
self.send_headers(200, "text/html; charset=utf-8")
self.wfile.write(page)
def reject_write(self):
self.send_headers(405)
self.wfile.write(b"Method Not Allowed")
do_POST = reject_write
do_PUT = reject_write
do_PATCH = reject_write
do_DELETE = reject_write
def log_message(self, fmt, *args):
print("demo:", fmt % args)
if __name__ == "__main__":
if not ROOT.is_dir() or not TOKEN:
raise SystemExit("请检查 SKILLS_ROOT,并设置非空 DEMO_TOKEN")
ThreadingHTTPServer(("127.0.0.1", 8000), Handler).serve_forever()
PY
注意,页面上的“允许访问路径”只是评审资料,不会授予操作系统权限。真正安装技能时,仍要在 Agent 的运行环境中限制文件范围、工具权限和凭据;演示页不能替代运行时隔离。
4 启动页面并验证只读边界
4.1 生成一次性访问 token
不要把固定口令写进脚本。开一个终端,执行:
export SKILLS_ROOT="$HOME/agent-skills-demo"
export DEMO_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')"
printf '本次验收 token:%s\n' "$DEMO_TOKEN"
python3 "$HOME/skill_demo_server.py"
终端会保持运行。把打印出的 token 暂存在当前验收记录中,验收结束后删除聊天里的链接;它不应该进入 Git 仓库、截图或文章正文。
4.2 在本机检查页面和写请求
再开一个终端,把 这里替换为刚才的token 换成真实值:
curl -i "http://127.0.0.1:8000/?token=这里替换为刚才的token"
curl -i -X POST "http://127.0.0.1:8000/?token=这里替换为刚才的token"
第一条应返回 HTTP/1.0 200 OK 和 HTML;第二条应返回 HTTP/1.0 405 Method Not Allowed。这一步不是为了测试而测试,而是确认展示链路正常、写请求确实被拒绝。
如果第一条返回 403,优先检查两个终端使用的 token 是否完全一致;如果连接失败,检查服务终端有没有退出,以及 8000 端口是否被占用:
lsof -nP -iTCP:8000 -sTCP:LISTEN

这张图应同时保留浏览器里的技能卡片和终端中的 405 响应。评审人一眼能看出“展示正常”和“没有写接口”是两件都验证过的事。
5 安装 cpolar,建立短时 HTTPS 验收入口
本地页跑通后再开公网入口,排错会轻松很多。这里使用 cpolar 的 HTTP 隧道,只映射 8000,不要映射 Agent 控制端口、SSH、数据库或 cpolar 的 9200 管理端口。
5.1 在 macOS 安装并启动 cpolar
macOS 可用 Homebrew 安装:
brew tap probezy/core && brew install cpolar
sudo cpolar service install
sudo cpolar service start
cpolar version
安装后打开 http://127.0.0.1:9200 登录。也可以登录 cpolar 后台,在“验证”页面复制 Authtoken,再用命令行绑定:
cpolar authtoken 你的Authtoken
如果 9200 页面打不开,先检查 cpolar 服务状态,不要急着改 Python 服务。页面服务和隧道服务是两条链路,分开确认更容易找到问题。
5.2 临时映射本地 8000 端口
另开终端运行:
cpolar http 8000
这是前台临时隧道,关闭该命令所在终端后就会停止。进入 http://127.0.0.1:9200 的“状态 → 在线隧道列表”,复制生成的 HTTPS 公网地址,再把 token 加到查询参数中:
https://实际生成的公网地址/?token=本次验收token
只把完整链接发给本次评审人。免费随机公网地址会在 24 小时内变化,正适合这类短时验收;本文不配置固定域名,也不把随机地址写进文档。

这里应展示手机打开后的只读页面,并遮住公网域名中的敏感标识和完整 token。图片下方要说明:外部设备看到的内容与本机一致,页面没有安装、运行、上传和下载按钮。
6 按权限清单验收,而不是只看页面好不好看
分享链接后,建议让团队按固定顺序检查:
- 技能名称与摘要是否说清了用途,有没有把“审查”写成“自动修改”;
- 所需权限是否符合最小授权,纯审查技能不应申请写文件权限;
- 允许路径是否限定在测试仓库,没有用户主目录、密钥目录和系统目录;
- 模拟结果是否脱敏,没有源码片段、账号、域名、token 与内部 IP;
- 错误 token、无 token、POST 请求是否分别返回 403、403、405。
我不建议在这个页面加“立即安装”按钮。验收与安装放在同一入口,评审人很容易把一次浏览变成真实变更;保留人工批准、离线安装这一步,反而更稳。
如果技能确实需要网络、Shell 或写文件,把用途写到权限项里,并明确目标范围。像“完全访问”“按需执行”这类描述没有评审价值,应该改成“只读取 demo-repo/src”或“仅执行固定的测试命令”这种可核对边界。
7 验收结束后关闭入口并清理现场
短时分享最关键的一步,是按时收口。评审结束后,在运行 cpolar http 8000 的终端按 Ctrl+C;再到在线隧道列表确认该临时隧道不在线。
随后在 Python 服务终端按 Ctrl+C,并检查 8000 端口已经释放:
lsof -nP -iTCP:8000 -sTCP:LISTEN
unset DEMO_TOKEN
unset SKILLS_ROOT
lsof 没有输出,表示当前没有进程监听 8000 端口。如果仍有结果,按输出中的 PID 找到对应进程,确认它是不是本次启动的 Python 服务,别上来就批量结束所有 Python 进程。
演示目录是否删除取决于团队流程。保留它时,也只保留公开说明和脱敏清单;每次评审都重新生成 token,并重新检查目录内容,不能因为“上次发过”就跳过清理。
8 总结
现在我们得到了一条很克制的 Agent Skill 验收链路:专用目录保存公开元数据,Python 服务只负责只读展示,一次性 token 挡住无关访问,cpolar 只在评审期间提供 HTTPS 入口。真正的技能执行环境、Shell、写入接口和密钥都没有出现在分享链路里。
- 演示前:复制必要说明,人工填写权限边界,清掉配置、日志和真实路径;
- 演示中:只映射
127.0.0.1:8000对应的页面端口,用完整 token 链接定向分享; - 演示后:依次停止 cpolar 与 Python 服务,确认端口释放,并撤掉一次性 token。
技能越多,越不能靠“大家先装上试试”来做治理。先把能力和权限摊开给团队看,再进入安装与真实测试,排错成本更低,也更容易守住安全边界;需要跨地点验收时,cpolar 只承担短时传递页面这一小段工作,够用就停,反而最省事。
更多推荐


所有评论(0)