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,排除脚本、密钥与日志

这张图应展示整理后的目录结构:每个技能只有 SKILL.mdmanifest.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

只读技能页面 GET 请求展示正常,POST 写请求返回 405

这张图应同时保留浏览器里的技能卡片和终端中的 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 小时内变化,正适合这类短时验收;本文不配置固定域名,也不把随机地址写进文档。

团队成员通过 cpolar 短时入口在手机上验收本机只读技能页面

这里应展示手机打开后的只读页面,并遮住公网域名中的敏感标识和完整 token。图片下方要说明:外部设备看到的内容与本机一致,页面没有安装、运行、上传和下载按钮。

6 按权限清单验收,而不是只看页面好不好看

分享链接后,建议让团队按固定顺序检查:

  1. 技能名称与摘要是否说清了用途,有没有把“审查”写成“自动修改”;
  2. 所需权限是否符合最小授权,纯审查技能不应申请写文件权限;
  3. 允许路径是否限定在测试仓库,没有用户主目录、密钥目录和系统目录;
  4. 模拟结果是否脱敏,没有源码片段、账号、域名、token 与内部 IP;
  5. 错误 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 只承担短时传递页面这一小段工作,够用就停,反而最省事。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐