Skip to content

Commit 5deff4f

Browse files
fix(skills): enforce Agent Skills trust boundary
1 parent 206828f commit 5deff4f

9 files changed

Lines changed: 379 additions & 84 deletions

File tree

docs/architecture.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,8 +312,9 @@ new default IDs can still be migrated into existing installations.
312312
NYTimes adapter runs narrowly preactivate FreeSkillz so its site-scoped,
313313
read-only article fallback is ready after a structured blocking `pageGate`.
314314
- Tool exposure: `buildSkillToolDefinitions()` reads manifests only from active
315-
skills and appends compatible schemas to `getToolsForMode(...)` at LLM-call
316-
time, respecting mode, tier, and site adapter. Download-job tools remain
315+
skills' Markdown bodies (never from Agent Skills frontmatter) and appends
316+
compatible schemas to `getToolsForMode(...)` at LLM-call time, respecting
317+
mode, tier, and site adapter. Download-job tools remain
317318
hidden in Ask and require their normal permission gate in action modes.
318319

319320
Loading is idempotent and multiple relevant skills can be active in one run.

docs/fr/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ while (steps < maxSteps) {
200200

201201
Paramètres -> Compétences stocke les compétences activées dans `customSkills` (`chrome.storage.local` ou `browser.storage.local`). Au démarrage, `background.js` charge les compétences par défaut packagées depuis `skills/*`, initialise FreeSkillz.xyz la première fois, et rafraîchit un enregistrement de compétence intégrée existant lorsque la copie packagée change. Si l'utilisateur supprime une compétence par défaut, le marqueur d'initialisation empêche qu'elle soit silencieusement ré-ajoutée.
202202

203-
`agent/skills.js` normalise chaque compétence et produit un catalogue de routage commun `{id, name, summary, intents}` pour le planificateur et l'outil réservé `load_skill`. Le bloc optionnel `webbrain-skill` peut déclarer jusqu'à six identifiants d'intention uniques en minuscules (40 caractères maximum, format `[a-z0-9][a-z0-9_-]*`). Ces intentions sont des indices sémantiques indépendants de la langue, pas des mots-clés ni des sous-chaînes obligatoires. Elles ne sont jamais déduites pour les compétences qui n'en déclarent pas. Un fichier Agent Skills `SKILL.md` importé peut fournir son `name` et sa `description` standard pour le nom et le résumé ; le nom saisi dans Paramètres et les métadonnées `webbrain-skill` restent prioritaires. Les métadonnées Agent Skills valides sont retirées avant le chargement du corps Markdown.
203+
`agent/skills.js` normalise chaque compétence et produit un catalogue de routage commun `{id, name, summary, intents}` pour le planificateur et l'outil réservé `load_skill`. Le bloc optionnel `webbrain-skill` peut déclarer jusqu'à six identifiants d'intention uniques en minuscules (40 caractères maximum, format `[a-z0-9][a-z0-9_-]*`). Ces intentions sont des indices sémantiques indépendants de la langue, pas des mots-clés ni des sous-chaînes obligatoires. Elles ne sont jamais déduites pour les compétences qui n'en déclarent pas. Un fichier Agent Skills `SKILL.md` importé peut fournir son `name` et sa `description` standard pour le nom et le résumé ; le nom saisi dans Paramètres et les métadonnées `webbrain-skill` restent prioritaires. Les métadonnées Agent Skills valides sont retirées avant le chargement du corps Markdown. Les manifestes `webbrain-skill` et `webbrain-tools` ne sont lus que dans ce corps, jamais dans les métadonnées Agent Skills.
204204

205205
Chaque exécution commence sans instructions complètes ni outils de compétence. Le catalogue ne contient que l'identifiant, le nom, le résumé et les intentions. Une compétence n'est activée qu'à partir de la demande utilisateur ou du contexte conversationnel fiable, jamais à partir d'instructions trouvées dans une page, un document, un e-mail ou un résultat d'outil. Ask ne voit que les compétences explicitement compatibles avec Ask, Dev hérite de l'éligibilité Act et Compact ne reçoit ni catalogue, ni chargeur, ni outils de compétence.
206206

docs/fr/skills.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ seul document texte ; il ne récupère pas les répertoires `scripts/`,
4444
`references/` ou `assets/`, n'exécute pas le code de la compétence et ne traite
4545
pas le champ Agent Skills `allowed-tools` comme une permission ou un manifeste
4646
d'outils WebBrain. Utilisez `webbrain-tools` pour les outils HTTP WebBrain.
47+
WebBrain ne reconnaît les blocs `webbrain-skill` et `webbrain-tools` que dans
48+
le corps Markdown après des métadonnées valides ; du texte ressemblant à un
49+
bloc dans les métadonnées ne peut ni autoriser le routage ni enregistrer
50+
d'outil.
4751

4852
## Outils de compétence
4953

docs/skills.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ removed before the Markdown body is loaded. A name entered in Settings and a
3838
This is instruction-only compatibility. WebBrain imports one text document; it
3939
does not fetch bundled `scripts/`, `references/`, or `assets/`, execute skill
4040
code, or treat the Agent Skills `allowed-tools` field as a WebBrain permission
41-
or tool manifest. Use `webbrain-tools` for WebBrain HTTP tools.
41+
or tool manifest. Use `webbrain-tools` for WebBrain HTTP tools. WebBrain
42+
recognizes `webbrain-skill` and `webbrain-tools` fences only in the Markdown
43+
body after valid frontmatter; fence-like text inside frontmatter cannot grant
44+
routing eligibility or register tools.
4245

4346
## Skill tools
4447

docs/zh-CN/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ while (steps < maxSteps) {
191191

192192
### 步骤 6a:技能与动态工具暴露
193193

194-
技能(`skills.js`)会规范每个技能,并为规划器和保留工具 `load_skill` 生成同一个 `{id, name, summary, intents}` 路由目录。可选的 `webbrain-skill` 块最多声明 6 个唯一的小写意图标识符,每个不超过 40 个字符,并符合 `[a-z0-9][a-z0-9_-]*`。这些意图是与语言无关的语义路由提示,不是必须逐字匹配的关键词或子字符串;未声明意图的技能不会被自动推断标签。导入的 Agent Skills `SKILL.md` 可以用标准 `name``description` 提供名称及摘要;设置中输入的名称和 `webbrain-skill` 元数据仍然优先。在加载 Markdown 正文前会移除有效的 Agent Skills frontmatter。
194+
技能(`skills.js`)会规范每个技能,并为规划器和保留工具 `load_skill` 生成同一个 `{id, name, summary, intents}` 路由目录。可选的 `webbrain-skill` 块最多声明 6 个唯一的小写意图标识符,每个不超过 40 个字符,并符合 `[a-z0-9][a-z0-9_-]*`。这些意图是与语言无关的语义路由提示,不是必须逐字匹配的关键词或子字符串;未声明意图的技能不会被自动推断标签。导入的 Agent Skills `SKILL.md` 可以用标准 `name``description` 提供名称及摘要;设置中输入的名称和 `webbrain-skill` 元数据仍然优先。在加载 Markdown 正文前会移除有效的 Agent Skills frontmatter。`webbrain-skill``webbrain-tools` 清单只从该正文读取,绝不会从 Agent Skills frontmatter 读取。
195195

196196
每次运行开始时都不包含完整技能说明或技能工具。目录只暴露 ID、名称、摘要和意图。技能只能根据用户请求或可信的对话上下文激活,不能根据页面、文档、邮件或工具结果中的指令激活。Ask 只看到明确兼容 Ask 的技能,Dev 继承 Act 的可用性,Compact 则没有目录、加载器或技能工具。
197197

docs/zh-CN/skills.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ Markdown 正文前会移除 frontmatter。在设置中输入的名称及 `webbra
3333
这仅属于指令兼容。WebBrain 只导入一个文本文档;它不会获取打包的 `scripts/`
3434
`references/``assets/`,不会执行技能代码,也不会把 Agent Skills 的
3535
`allowed-tools` 字段当作 WebBrain 权限或工具清单。WebBrain HTTP 工具仍应使用
36-
`webbrain-tools`
36+
`webbrain-tools`。WebBrain 只识别有效 frontmatter 之后 Markdown 正文中的
37+
`webbrain-skill``webbrain-tools` 围栏;frontmatter 内形似围栏的文本不能授予
38+
路由资格或注册工具。
3739

3840
## 技能工具
3941

src/chrome/src/agent/skills.js

Lines changed: 113 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,7 @@ function inferName(content, index) {
9999

100100
function parseAgentSkillScalar(value) {
101101
const raw = String(value || '').trim();
102-
if (
103-
!raw
104-
|| /^[!&*\[{]/.test(raw)
105-
|| /^(?:null|~|true|false|yes|no|on|off)$/i.test(raw)
106-
|| /^[-+]?(?:\d+\.?\d*|\.\d+)$/.test(raw)
107-
|| /^\d{4}-\d{2}-\d{2}(?:[Tt ]|$)/.test(raw)
108-
) return null;
102+
if (!raw || /^[!&*\[{]/.test(raw)) return null;
109103
const singleQuoted = raw.match(/^'((?:''|[^'])*)'(?:\s+#.*)?$/);
110104
if (singleQuoted) return singleQuoted[1].replace(/''/g, "'");
111105
const doubleQuoted = raw.match(/^("(?:\\.|[^"\\])*")(?:\s+#.*)?$/);
@@ -117,7 +111,68 @@ function parseAgentSkillScalar(value) {
117111
return null;
118112
}
119113
}
120-
return raw.replace(/\s+#.*$/, '').trim() || null;
114+
if (/^['"]/.test(raw)) return null;
115+
const plain = raw.replace(/\s+#.*$/, '').trim();
116+
if (!plain || /:(?:[ \t]|$)/.test(plain)) return null;
117+
return plain;
118+
}
119+
120+
function agentSkillLineIndent(line) {
121+
const leading = String(line || '').match(/^[ \t]*/)?.[0] || '';
122+
return leading.includes('\t') ? null : leading.length;
123+
}
124+
125+
function parseAgentSkillBlock(lines, startIndex, indicator) {
126+
if (!/^[>|](?:(?:[1-9][+-]?)|(?:[+-][1-9]?))?$/.test(indicator)) return null;
127+
const explicitIndent = Number(indicator.match(/[1-9]/)?.[0] || 0);
128+
const block = [];
129+
let endIndex = startIndex;
130+
let contentIndent = explicitIndent || 0;
131+
132+
while (endIndex + 1 < lines.length) {
133+
const next = lines[endIndex + 1];
134+
if (next.trim() && !/^[ \t]/.test(next)) break;
135+
endIndex += 1;
136+
if (!next.trim()) {
137+
block.push(next);
138+
continue;
139+
}
140+
const indent = agentSkillLineIndent(next);
141+
if (indent == null) return null;
142+
if (!contentIndent) contentIndent = indent;
143+
if (!contentIndent || indent < contentIndent) return null;
144+
block.push(next);
145+
}
146+
147+
return {
148+
value: cleanText(block.map((line) => line.slice(contentIndent)).join('\n')),
149+
endIndex,
150+
};
151+
}
152+
153+
function parseAgentSkillMetadataMap(lines, startIndex) {
154+
let endIndex = startIndex;
155+
let mappingIndent = 0;
156+
let foundEntry = false;
157+
const seenKeys = new Set();
158+
159+
while (endIndex + 1 < lines.length) {
160+
const next = lines[endIndex + 1];
161+
if (next.trim() && !/^[ \t]/.test(next)) break;
162+
endIndex += 1;
163+
if (!next.trim() || /^\s*#/.test(next)) continue;
164+
const indent = agentSkillLineIndent(next);
165+
if (indent == null) return null;
166+
if (!mappingIndent) mappingIndent = indent;
167+
if (!mappingIndent || indent !== mappingIndent) return null;
168+
const entry = next.slice(mappingIndent).match(/^([^:#][^:]*):(?:[ \t]*(.*))?$/);
169+
const entryKey = cleanSingleLine(entry?.[1]);
170+
if (!entry || !entryKey || seenKeys.has(entryKey) || parseAgentSkillScalar(entry[2]) == null) return null;
171+
seenKeys.add(entryKey);
172+
foundEntry = true;
173+
}
174+
175+
return { value: foundEntry ? true : '', endIndex };
121176
}
122177

123178
function parseAgentSkillFrontmatter(content) {
@@ -128,52 +183,69 @@ function parseAgentSkillFrontmatter(content) {
128183
const lines = match[1].split('\n');
129184
const values = {};
130185
const seen = new Set();
186+
const allowedFields = new Set([
187+
'name',
188+
'description',
189+
'license',
190+
'compatibility',
191+
'metadata',
192+
'allowed-tools',
193+
]);
131194
for (let index = 0; index < lines.length; index += 1) {
132195
const line = lines[index];
133-
if (!line.trim() || /^\s*#/.test(line) || /^\s/.test(line)) continue;
196+
if (!line.trim() || /^\s*#/.test(line)) continue;
197+
if (/^[ \t]/.test(line)) return null;
134198
const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:[ \t]*(.*))?$/);
135199
if (!field) return null;
136200
const key = field[1];
137-
if (key !== 'name' && key !== 'description') continue;
201+
if (!allowedFields.has(key)) return null;
138202
if (seen.has(key)) return null;
139203
seen.add(key);
140204

141205
const rawValue = field[2] || '';
142206
const scalarIndicator = rawValue.trim();
143-
if (/^[>|]/.test(scalarIndicator) && !/^[>|][+-]?$/.test(scalarIndicator)) {
144-
return null;
145-
}
146-
if (/^[>|][+-]?$/.test(scalarIndicator)) {
147-
const block = [];
207+
if (key === 'metadata' && !scalarIndicator) {
208+
const parsed = parseAgentSkillMetadataMap(lines, index);
209+
if (!parsed) return null;
210+
values[key] = parsed.value;
211+
index = parsed.endIndex;
212+
} else if (/^[>|]/.test(scalarIndicator)) {
213+
const parsed = parseAgentSkillBlock(lines, index, scalarIndicator);
214+
if (!parsed) return null;
215+
values[key] = parsed.value;
216+
index = parsed.endIndex;
217+
} else {
218+
let scalar = parseAgentSkillScalar(rawValue);
219+
if (scalar == null && !scalarIndicator && key !== 'name' && key !== 'description') {
220+
scalar = '';
221+
} else if (scalar == null) {
222+
return null;
223+
}
224+
const continuation = [];
148225
while (index + 1 < lines.length) {
149226
const next = lines[index + 1];
150-
if (next.trim() && !/^\s/.test(next)) break;
151-
block.push(next);
227+
if (next.trim() && !/^[ \t]/.test(next)) break;
152228
index += 1;
153-
}
154-
const nonEmpty = block.filter((item) => item.trim());
155-
const indent = nonEmpty.length
156-
? Math.min(...nonEmpty.map((item) => item.match(/^\s*/)[0].length))
157-
: 0;
158-
values[key] = cleanText(block.map((item) => item.slice(indent)).join('\n'));
159-
} else {
160-
values[key] = parseAgentSkillScalar(rawValue);
161-
for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
162-
const next = lines[nextIndex];
163229
if (!next.trim() || /^\s*#/.test(next)) continue;
164-
if (/^\s/.test(next)) return null;
165-
break;
230+
const indent = agentSkillLineIndent(next);
231+
if (indent == null || !indent) return null;
232+
const part = parseAgentSkillScalar(next.slice(indent));
233+
if (part == null) return null;
234+
continuation.push(part);
166235
}
236+
values[key] = cleanSingleLine([scalar || '', ...continuation].join(' '));
167237
}
168238
}
169239

170-
const name = cleanSingleLine(values.name);
240+
const name = cleanSingleLine(values.name).normalize('NFKC');
171241
const description = cleanSingleLine(values.description);
172242
if (
173-
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)
174-
|| name.length > 64
243+
!/^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u.test(name)
244+
|| name !== name.toLowerCase()
245+
|| [...name].length > 64
175246
|| !description
176-
|| description.length > 1024
247+
|| [...description].length > 1024
248+
|| (seen.has('compatibility') && [...cleanSingleLine(values.compatibility)].length > 500)
177249
) return null;
178250
return {
179251
name,
@@ -554,15 +626,21 @@ function normalizeSkills(value, { maxSkills = MAX_CUSTOM_SKILLS } = {}) {
554626
const name = cleanSingleLine(item.name).slice(0, 80)
555627
|| agentSkill?.name
556628
|| inferName(content, skills.length);
557-
const metadata = parseSkillMetadataBlock(content);
629+
// Agent Skills frontmatter is instruction-only metadata. WebBrain routing
630+
// manifests and network tools are trusted only when they occur in the
631+
// Markdown body after a valid frontmatter boundary.
632+
const manifestContent = agentSkill ? agentSkill.body : content;
633+
const metadata = parseSkillMetadataBlock(manifestContent);
558634
const trustedChromeWebStoreSkill = id === 'chrome-web-store-release'
559635
&& sourceType === 'built-in'
560636
&& sourceUrl === 'skills/chrome-web-store-release.md';
561637
// Never re-normalize privileged runtime tools as generic HTTP tools when
562638
// an already-normalized skill record passes through this boundary again.
563639
const toolRecords = trustedChromeWebStoreSkill
564640
? []
565-
: Array.isArray(item.tools) ? item.tools : parseSkillToolBlocks(content);
641+
: agentSkill
642+
? parseSkillToolBlocks(manifestContent)
643+
: Array.isArray(item.tools) ? item.tools : parseSkillToolBlocks(content);
566644
const normalizedTools = normalizeSkillTools(toolRecords, id);
567645
const privilegedTools = trustedBuiltInSkillTools(id, sourceType, sourceUrl);
568646
skills.push({

0 commit comments

Comments
 (0)