Skip to content

Commit cf4f82a

Browse files
committed
fix(validator): 第三轮评审加固(命令路径/YAML 注释/点前缀清单/描述长度)
针对 PR #81 第三轮复审,全部落在 validate_portable_plugin.py: - stdio command 拒绝绝对路径与 ../ 穿越:只允许裸可执行名或 ./ 开头的 插件相对路径(§7.2.1),/tmp/tool、../../outside 不再当作 PATH 解析放过 - frontmatter 值解析剥离注释:description: # omitted 视为缺失而非接受注释; 行尾 \" # note\" 同样剥离 - 移除 .mcp.json 提示分支:按仓库策略,点前缀文件不出现在检查/发现/报告面 - 技能 description 增加 1024 字符上限(Agent Skills 限制,防客户端拒绝) 注:镜像 hooks.json 中 \${CLAUDE_PLUGIN_ROOT} 的按客户端改写(OpenHands 用 PLUGIN_ROOT)属后续适配工作,已在评审串回复说明。
1 parent d787887 commit cf4f82a

1 file changed

Lines changed: 26 additions & 8 deletions

File tree

‎scripts/validate_portable_plugin.py‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,15 @@ def _check_stdio(where: str, entry: dict, root: Path, report: Report) -> None:
376376
where,
377377
f"command {command!r} escapes the plugin root (§4.1).",
378378
)
379+
elif "/" in command:
380+
# §7.2.1: command is either a bare executable name (resolved by platform
381+
# search) or a plugin-relative path beginning with ./ . An absolute path
382+
# or a ../ traversal is neither and must not pass as PATH-resolved.
383+
report.fail(
384+
where,
385+
f"command {command!r} must be a bare executable name or a plugin-relative "
386+
f"path beginning with './' (§7.2.1).",
387+
)
379388

380389
args = entry.get("args")
381390
if args is not None and (
@@ -581,11 +590,18 @@ def check_skill_md(where: str, skill_dir: Path, path: Path, report: Report) -> N
581590
report.fail(where, "unterminated YAML frontmatter")
582591
return
583592

593+
# Extract `key: value` pairs. A value that is empty or comment-only
594+
# (`description: # omitted`) is not a value; treat it as absent so the
595+
# required-field checks fire instead of silently accepting a comment.
584596
fields: dict[str, str] = {}
585597
for line in lines[1:end]:
586598
match = re.match(r"^([A-Za-z_-]+):\s*(.*)$", line)
587599
if match:
588-
fields[match.group(1)] = match.group(2).strip().strip('"').strip("'")
600+
raw = match.group(2).strip()
601+
# a value that is empty or comment-only (`description: # omitted`)
602+
# is not a value; a trailing ` # comment` is stripped too
603+
raw = "" if raw.startswith("#") else re.sub(r"\s+#.*$", "", raw).strip()
604+
fields[match.group(1)] = raw.strip('"').strip("'")
589605

590606
name = fields.get("name", "")
591607
if not name:
@@ -605,8 +621,16 @@ def check_skill_md(where: str, skill_dir: Path, path: Path, report: Report) -> N
605621
f"frontmatter name {name!r} does not match its directory "
606622
f"{skill_dir.name!r}; discovery is by directory (§7.1).",
607623
)
608-
if not fields.get("description"):
624+
description = fields.get("description", "")
625+
if not description:
609626
report.fail(where, "frontmatter is missing `description`")
627+
elif len(description) > 1024:
628+
# Agent Skills limits `description` to 1,024 characters; a client may
629+
# reject a skill that the validator accepted.
630+
report.fail(
631+
where,
632+
f"frontmatter description is {len(description)} characters, must be at most 1024",
633+
)
610634

611635

612636
# --- client extension directories (§8.2) ----------------------------------
@@ -759,12 +783,6 @@ def validate(root: Path) -> Report:
759783
report.note(f"{count} skill(s) discovered under skills/")
760784
check_client_extensions(root, report)
761785
check_parity(root, plugin, report)
762-
if (root / ".mcp.json").is_file() and not (root / "mcp.json").is_file():
763-
report.note(
764-
"`.mcp.json` is present but `mcp.json` is not; Agent Plugins reads only "
765-
"`mcp.json` at the plugin root, so these MCP servers are invisible to a "
766-
"conformant client (§6.1, §7.2.1)."
767-
)
768786
return report
769787

770788

0 commit comments

Comments
 (0)