gstack Domain Skills 深度解析:Agent 自写的站点笔记如何持久化、晋升与防注入
【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack
Domain Skills 是 gstack 中浏览器 Agent 的“跨会话站点记忆”机制:Agent 在某个网站上总结出非显而易见的操作技巧后,会把笔记保存为该站点的 domain skill,后续同一站点的新会话会自动把笔记注入 prompt 上下文。本文基于 docs/domain-skills.md 与 browse/src/domain-skills.ts、browse/src/domain-skill-commands.ts 等源码实现,完整讲解其 CLI 用法、三态状态机、JSONL 存储纪律、五层安全模型与错误排查,读完你可以理解这套“Agent 给 Agent 写笔记”机制的全貌与防 prompt injection 设计。
一、Domain Skills 是什么:Agent 给未来的自己留的便签
gstack 的浏览器 Agent(browse 模块)在执行网页任务时会遇到大量站点特有的“隐性知识”,例如某个按钮藏在 iframe 里、某页面需要先展开折叠面板。Domain Skills 就是让 Agent 把这些经验写下来、跨会话复用的机制,官方描述为:
Per-site notes the agent writes for itself. Compounds across sessions: once an agent figures out something non-obvious about a website, it saves a skill, and future sessions on that host get the note injected into their prompt context.
这个模式借鉴自 browser-use/browser-harness 项目的 per-site-notes 思路,但 gstack 只拷贝了“按站点记笔记”的模式,不拷贝自修改运行时(self-modifying-runtime)的模式。文档明确强调:skill 是加载进 prompt 的 markdown 纯文本,不是可执行代码——这决定了它的安全边界:skill 的内容本质上是 agent 撰写的、会进入未来 prompt 上下文的不可信内容,因此整套机制的重头戏在于防注入与晋升管控(详见第五节)。
二、CLI 实战:$B domain-skill子命令全集
gstack 文档中$B指 browse 守护进程的 CLI 入口,domain-skill 是其下的元命令(在 browse/src/commands.ts 中注册于Meta分类)。完整的子命令面如下,源码实现在 browse/src/domain-skill-commands.ts:
# Agent 任务成功后写下学到的站点知识。 # 注意:host 自动取自当前激活标签页(不需要 agent 传参数) echo "# LinkedIn Apply Button The Apply button on /jobs/view pages is inside an iframe with a class matching 'jobs-apply-button-iframe'. Use \$B frame --url 'apply' first, then snapshot." | $B domain-skill save # 查看已保存的 skill $B domain-skill list # 读取某个 host 的 skill 正文 $B domain-skill show linkedin.com # 用 $EDITOR 交互式编辑 $B domain-skill edit linkedin.com # 把当前项目的 skill 提升为全局(跨项目生效) $B domain-skill promote-to-global linkedin.com # 回滚最近一次编辑 $B domain-skill rollback linkedin.com # 删除(tombstone,可通过 rollback 恢复) $B domain-skill rm linkedin.com各子命令的关键行为,均可在 browse/src/domain-skill-commands.ts 中对应到具体 handler:
| 子命令 | 行为 | 源码要点 |
|---|---|---|
save | 正文从stdin 或--from-file <path>读取,绝不接受内联 argv(多行 markdown 会被 shell 引号搞坏);host 从激活标签页顶层 origin 派生 | readBodyFromArgs+deriveHostFromActiveTab;空正文直接报Save failed: empty body |
list | 分组展示当前项目可见的 project 与 global skill,含状态、版本、字节数、使用次数与标记数 | listSkills只返回非 tombstone 行 |
show <host> | 打印 skill 正文,头部附 host、来源 scope、版本、使用次数与 flag 数 | 只返回 active(项目)或 global 状态的 skill,quarantined 的不显示 |
edit <host> | 把当前正文写入临时文件,拉起$EDITOR(缺省vi)编辑,保存后以source: 'human'重新落盘 | 内容与原文相同则输出No changes,不产生新版本 |
promote-to-global <host> | 仅允许把active状态的项目级 skill 提升为 global;skill 在全局文件中从 v1 重新计数 | 非 active 状态直接抛错 |
rollback <host> [--global] | 恢复上一个版本,版本号单调递增(回滚内容本身成为新版本) | 少于 2 个版本时拒绝 |
rm <host> [--global] | 追加 tombstone 行,不物理删除 | 输出提示可用rollback恢复 |
一个值得注意的设计:save命令输出的Saved结果会明确告知用户“skill 目前是 quarantined,未连续使用 3 次(且不被分类器标记)前不会在 prompt 中生效”,即保存成功不等于立即生效。
主机名归一化规则
save不接受 agent 提供的 host 参数,而是调用 browse/src/domain-skills.ts 中的deriveHostFromActiveTab从当前页面 URL 派生。派生前先经normalizeHost归一化,规则为:
- 转小写、去首尾空白;
- 剥掉协议前缀(
https?://)、路径、query、hash; - 剥掉端口号;
- 剥掉
www.前缀,但保留完整子域名(按子域精确匹配); - Punycode 域名按编码后原样存储。
三、状态机:quarantined → active → global
这是整个机制的核心,防止被污染的页面笔记直接“洗”进长期记忆。文档给出的状态机如下:
┌──────────────┐ 3 successful uses ┌────────┐ promote-to-global ┌────────┐ │ quarantined │ ─────────────────────▶ │ active │ ──────────────────▶ │ global │ │ (per-project)│ (no classifier flags) │(project)│ (manual command) │ │ └──────────────┘ └────────┘ └────────┘ ▲ │ │ classifier flag during use │ rollback (version log) └───────────────────────────────────────┘源码中对应SkillState = 'quarantined' | 'active' | 'global',晋升阈值常量PROMOTE_THRESHOLD = 3(见 browse/src/domain-skills.ts)。三个关键语义:
新保存的 skill 一律是
quarantined,且绝不自动注入 prompt。readSkill的实现明确只返回state === 'active'(项目层)或state === 'global'(全局层)的行,quarantined 行对读取路径完全不可见。自动晋升需要同时满足四个条件,由
recordSkillUse维护:use_count累计达到 3;flag_count === 0(使用期间从未被分类器标记);- 当前状态仍是
quarantined; classifier_score > 0(正文确实被 L4 ML 扫过)。
关于最后一条门槛,源码注释交代了一个重要现状:save 路径写入的
classifier_score固定为 0(含义是“从未被 ML 扫描过”),而加载期的 L4 扫描路径已随旧 chat 路径一起移除,因此生产路径写入的 skill 目前无法通过自动晋升——这个classifier_score > 0门槛正是补偿性防线:一旦 L4 重新接线并写入非零分数,自动晋升即自动恢复。换言之,当前版本中 quarantined skill 想进入 prompt 需要等待 L4 重新接入,或被手动处理;这与文档描述的“设计意图”存在时差,从源码注释看属于有意保留的开关。跨项目晋升(global)必须显式人工执行
$B domain-skill promote-to-global <host>,永不自动。文档给出的理由(源自 Codex T4 outside-voice 评审)是:无差别的跨项目记忆复利会在不相关项目之间泄漏上下文。
读取优先级上,readSkill先查项目层、再 fallback 全局层:项目级 active skill 会遮蔽(shadow)同 host 的 global skill。这让你可以在某个项目里为同一网站写一份定制版笔记,而全局笔记继续服务其他项目。
四、存储层:双 JSONL 文件与追加写纪律
Skill 存储在两个位置:
- 项目级:
~/.gstack/projects/<slug>/learnings.jsonl—— 与/learnskill 共用同一个 JSONL 文件,domain skill 是其中type:"domain"的行(同一文件里还有学习记录等其他行,解析器按type === 'domain'过滤); - 全局级:
~/.gstack/global-domain-skills.jsonl—— 只存放state:"global"的行。
两个文件都是追加写(append-only)JSONL。每行是一个完整的DomainSkillRow,字段结构在 browse/src/domain-skills.ts 中定义:
export interface DomainSkillRow { type: 'domain'; host: string; // 归一化后的 host scope: 'project' | 'global'; state: 'quarantined' | 'active' | 'global'; body: string; // markdown 正文 version: number; // 版本计数器,单调递增 classifier_score: number; // 0 表示“从未被 L4 扫描” source: 'agent' | 'human'; sha256: string; // 正文摘要 use_count: number; flag_count: number; created_ts: string; updated_ts: string; tombstone?: boolean; // 删除标记 }docs/domain-skills.md 描述的三条存储纪律,在源码中逐条可验证:
- 原子追加:
appendRow用O_WRONLY | O_CREAT | O_APPEND打开文件后单次writeSync加fsyncSync。源码注释说明依据:POSIX 对小于PIPE_BUF(通常 4KB)的 O_APPEND 写保证原子性,而单行 JSON 远小于该上限,因此即使并发写入也不会交错。 - 容忍式解析:
readRows逐行JSON.parse,任何一行(尤其是崩溃时刻写了一半的尾行)解析失败即静默跳过,保证“写一半崩溃”不会毒化后续所有读取;TODOS.md 中亦记录了该 JSONL 存储的后续演进方向(多写者场景下考虑 SQLite)。 - 墓碑 + 压缩:
rm不删行,而是追加一行tombstone: true的新版本行;“latest-wins”解析时墓碑胜出,使被删的 skill 保持已删状态;空闲 compactor 周期性重写文件做物理清理。
版本解析逻辑(resolveLatest)按(scope, host)分组、取版本号最大的行,版本号在每次 save/use/rollback/tombstone 时单调 +1,因此“回滚”实际是把上一个版本的内容以新版本号重新落盘——历史完整保留,审计链不断。
五、安全模型:Agent 写的内容为何能进未来 prompt
文档对此的定性非常直接:“Skills are agent-authored content loaded into future prompt context. That makes them a classic agent-to-agent prompt-injection vector.”(这是典型的 agent 间 prompt 注入向量。)防御分五层,原文表格如下:
| Layer | 内容 | 位置 |
|---|---|---|
| L1-L3 | 数据水印、隐藏元素剥离、ARIA 正则、URL 黑名单 | content-security.ts(编译进二进制) |
| L4 | TestSavantAI ONNX 分类器 | security-classifier.ts(sidebar-agent,非编译) |
| L4b | Claude Haiku transcript 分类器 | security-classifier.ts(sidebar-agent) |
| L5 | 金丝雀令牌泄漏检测 | security.ts |
L1-L3 在 save 时运行(daemon 侧),L4 ML 分类器在 load 时运行,即每个把 skill 载入 prompt 的会话都会重新校验内容——这样才能抓住只在分类器模型更新后才显现的问题。下面结合源码展开各层。
L1-L3:save 时执行的内容过滤器
save命令在落盘前调用 browse/src/content-security.ts 的runContentFilters(body, page.url(), 'domain-skill-save'),被拦截则抛出Save blocked: ...并记录domain_skill_save_blocked遥测。该模块的四个防御层:
- L1 数据水印(Datamarking):
datamarkContent在每第 3 个句尾句号后插入由零宽空格包裹的会话级随机标记(randomBytes(3)取 base64 前 4 位),用于检测内容外泄——若这些不可见水印出现在别处,说明页面文本被原样搬运出去了。 - L2 隐藏元素剥离:
markHiddenElements在页面内检测 opacity < 0.1、字号 < 1px、屏幕外定位、前后景色相同、clip 隐藏、visibility:hidden等“人看不见、机器看得见”的元素,打上data-gstack-hidden标记后从提取文本中剔除;同时用一组注入特征正则(如ignore (previous|above|all) instructions、you are (now|a)、system:、do not follow/obey、new instructions:等)扫描 ARIA label,命中即标记。 - L3 URL 黑名单:内置一份 10 个数据外泄汇点域名的清单(
requestbin.com、pipedream.com、webhook.site、hookbin.com、requestcatcher.com、burpcollaborator.net、interact.sh、canarytokens.com、ngrok.io、ngrok-free.app),对页面 URL 和正文中出现的所有http(s)://URL 做大小写不敏感的子串匹配(源码注释指出这是为了防https://WEBHOOK.SITE/x这类大写绕过)。
过滤器受环境变量BROWSE_CONTENT_FILTER控制:off/warn(默认)/block。此外还有一个信封逃逸防护:wrapUntrustedPageContent会把内容中伪造的═══ BEGIN/END UNTRUSTED WEB CONTENT ═══哨兵串中间插入零宽空格使其失效,防止恶意页面提前闭合“不可信内容”边界、伪造出 LLM 眼中的“可信段”。
L4:TestSavantAI ONNX 注入分类器
docs/domain-skills.md 提到的 L4 在 browse/src/security-classifier.ts 中实现,几个可验证的实现细节:
- 模型是 TestSavantAI 的 BERT-small ONNX(约 112MB),首次使用时从 HuggingFace 下载到
~/.gstack/models/testsavant-small/,按@huggingface/transformers期望的onnx/model.onnx布局暂存; - 运行位置有讲究:该模块只能被 security sidecar 子进程导入,不能进入编译后的 browse 二进制——因为
onnxruntime-node这个原生模块无法从 Bun 编译二进制的临时解包目录dlopen; - fail-open 降级:模型加载失败或推理异常时返回
degraded状态、置信度 0,上层判定组合器回落到“仅 L1-L3”的结果,只有这一层额外防线消失,整体服务不中断;提供GSTACK_SECURITY_OFF=1环境开关可强制关闭 ML 分类器; - 输入先经
htmlToPlainText剥掉 HTML 标签(分类器是按纯文本训练的,标签噪声会稀释注入信号),再做 4000 字符上限与 512 token 截断(对应 BERT-small 的max_position_embeddings)。
而 skill 存储层对分数的硬门槛写在writeSkill中:classifier_score >= 0.85直接抛错Save blocked: classifier flagged content as potential injection,这正是文档错误表中“L4 score ≥ 0.85 at save”一行的来源(见 browse/src/domain-skills.ts 第 252-259 行的实现)。
防“confused deputy”:host 永不接受 agent 参数
文档特别强调的一条设计:save 从激活标签页的顶层 origin推导 host,而不是接受 agent 传参。这关闭了 Codex 评审标记的一个 confused-deputy 漏洞——否则恶意页面的重定向链可以诱骗 agent 把投毒内容写到另一个域名名下。deriveHostFromActiveTab还会拒绝about:blank与chrome://页面,抛出文档错误表中对应的Cannot save domain-skill: no top-level URL on active tab。
六、错误参考表
docs/domain-skills.md 的 Error reference 完整继承如下(每条错误信息在 browse/src/domain-skill-commands.ts 与 browse/src/domain-skills.ts 中均有逐字对应的 throw 语句,格式统一为“现象 + Cause + Action”三段式):
| Error | Cause | Action |
|---|---|---|
Save blocked: classifier flagged content as potential injection | save 时 L4 分数 ≥ 0.85 | 改写 skill 去掉指令式语句后重试 |
Save blocked: <L1-L3 message> | URL 黑名单命中或 ARIA 注入 | 检查 skill 正文中的可疑模式 |
Save failed: empty body | stdin 与--from-file都没内容 | 用管道把 markdown 喂给$B domain-skill save,或传--from-file <path> |
Cannot save domain-skill: no top-level URL on active tab | 标签页是about:blank或chrome://... | 先$B goto <target-site>再 save |
Cannot promote: skill is in state "quarantined" | 尚未自动晋升 | 在本项目继续使用该站点,直到 3 次成功使用且无分类器标记 |
Cannot rollback: <host> has fewer than 2 versions | 只有 1 个版本 | 改用$B domain-skill rm删除 |
七、遥测:只记 host,不记内容
当遥测开启时(默认community模式,除非显式关闭),domain-skill 相关事件写入~/.gstack/analytics/browse-telemetry.jsonl(事件清单同时记录在 browse/src/telemetry.ts 头注释中):
domain_skill_saved {host, scope, state, bytes}—— save 成功后记录(handleSave中logTelemetry调用可验证);domain_skill_save_blocked {host, reason}—— L1-L3 拦截时记录;domain_skill_fired {host, source, version}—— skill 被注入 prompt 时记录;domain_skill_state_changed {host, from_state, to_state}—— 文档标注为 planned(规划中)。
隐私边界:只上报主机名,不含正文、不含 agent 文本。完全关闭可用gstack-config set telemetry off或环境变量GSTACK_TELEMETRY_OFF=1。
八、测试与延伸阅读
该机制的测试覆盖集中在browse/test/目录,可作为行为佐证:
- browse/test/domain-skills-storage.test.ts —— 存储层单测(追加、解析、晋升门槛、回滚等);
- browse/test/domain-skills-e2e.test.ts —— CLI 端到端测试;
- browse/test/security-classifier.test.ts —— L4 分类器加载与降级行为。
相关源码入口:存储层 browse/src/domain-skills.ts(readSkill/writeSkill/recordSkillUse/promoteToGlobal/rollbackSkill/deleteSkill)、CLI 层 browse/src/domain-skill-commands.ts、内容安全 browse/src/content-security.ts、ML 分类器 browse/src/security-classifier.ts。命令注册与用法说明见 browse/src/commands.ts 中domain-skill条目;项目 slug 的解析(决定 per-project 存储路径)见 browse/src/project-slug.ts。
小结
Domain Skills 用一个极简的存储原语(追加写 JSONL + 墓碑 + 版本单调递增)实现了 Agent 的跨会话站点记忆,而真正的工程重心放在信任链上:quarantined 隔离态、3 次无标记才自动激活、跨项目晋升必须人工、host 强制来自浏览器受控状态、save/load 双时点过滤与 L1-L5 分层防御。它展示了一个典型的思路——当“Agent 生成的内容会进入 Agent 的未来 prompt”时,这套内容必须按外部不可信输入来设防。
【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考