Skip to content

Commit 2f78bdf

Browse files
committed
fix(validator): 按评审加固 validate_portable_plugin(上轮遗漏部署)
上一轮回补了钩子根解析,但工作区里加固后的校验器忘了重新部署到各包, 本提交补上。加固内容(针对 PR #81 评审): - 远程 MCP URL 完整解析(scheme+host、禁空白),不再只前缀匹配 - YAML frontmatter 分隔符必须整行 "---",不再接受 "---not-a-fence" - 镜像扫描跳过点前缀路径(.DS_Store 等) - parity 先判 extensions.com.openai 类型,标量值不再崩溃 - 错误信息不再指向未随包分发的工作区脚本
1 parent 1f4a1ee commit 2f78bdf

1 file changed

Lines changed: 39 additions & 14 deletions

File tree

‎scripts/validate_portable_plugin.py‎

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -413,10 +413,21 @@ def _check_remote(where: str, entry: dict, report: Report) -> None:
413413
f"url {url!r} must not contain placeholders; clients MUST NOT perform "
414414
f"placeholder or environment-variable expansion in url (§7.2.1).",
415415
)
416-
if not url.startswith(("http://", "https://")):
417-
report.fail(where, f"url {url!r} must be an absolute HTTP or HTTPS URL (§7.2.1)")
418-
elif "#" in url:
419-
report.fail(where, f"url {url!r} must not contain a fragment (§7.2.1)")
416+
# §7.2.1: an absolute HTTP or HTTPS URL. Parse, don't prefix-match — a bare
417+
# `https://` or a URL with an empty host or embedded whitespace must not pass.
418+
from urllib.parse import urlsplit
419+
420+
if any(ch.isspace() for ch in url):
421+
report.fail(where, f"url {url!r} must not contain whitespace (§7.2.1)")
422+
else:
423+
parts = urlsplit(url)
424+
if parts.scheme not in ("http", "https") or not parts.netloc:
425+
report.fail(
426+
where,
427+
f"url {url!r} must be an absolute HTTP or HTTPS URL with a host (§7.2.1)",
428+
)
429+
elif parts.fragment:
430+
report.fail(where, f"url {url!r} must not contain a fragment (§7.2.1)")
420431

421432
headers = entry.get("headers")
422433
if headers is not None:
@@ -488,16 +499,23 @@ def check_skills(root: Path, report: Report) -> tuple[int, list[str]]:
488499

489500
def check_skill_md(where: str, skill_dir: Path, path: Path, report: Report) -> None:
490501
text = path.read_text(encoding="utf-8", errors="replace")
491-
if not text.startswith("---"):
502+
# The opening delimiter must be a line that is exactly `---`; a prefix or
503+
# substring check would accept `---not-a-fence`.
504+
lines = text.splitlines()
505+
if not lines or lines[0].strip() != "---":
492506
report.fail(where, "missing YAML frontmatter; required by the Agent Skills spec (§7.1)")
493507
return
494-
end = text.find("\n---", 3)
508+
end = -1
509+
for index in range(1, len(lines)):
510+
if lines[index].strip() == "---":
511+
end = index
512+
break
495513
if end == -1:
496514
report.fail(where, "unterminated YAML frontmatter")
497515
return
498516

499517
fields: dict[str, str] = {}
500-
for line in text[3:end].splitlines():
518+
for line in lines[1:end]:
501519
match = re.match(r"^([A-Za-z_-]+):\s*(.*)$", line)
502520
if match:
503521
fields[match.group(1)] = match.group(2).strip().strip('"').strip("'")
@@ -522,18 +540,24 @@ def check_skill_md(where: str, skill_dir: Path, path: Path, report: Report) -> N
522540

523541

524542
def _content_files(directory: Path) -> dict[str, Path]:
525-
"""Map relative path -> file for every real content file under `directory`."""
543+
"""Map relative path -> file for every real content file under `directory`.
544+
545+
Dot-prefixed paths are skipped along with build caches — mirrors and scans
546+
must never treat `.DS_Store`, `.gitignore` or similar as content."""
526547
found: dict[str, Path] = {}
527548
if not directory.is_dir():
528549
return found
529550
for path in directory.rglob("*"):
530551
if not path.is_file():
531552
continue
553+
relative = path.relative_to(directory)
554+
if any(part.startswith(".") for part in relative.parts):
555+
continue
532556
if any(part in _IGNORED_PARTS for part in path.parts):
533557
continue
534558
if path.suffix in _IGNORED_SUFFIXES:
535559
continue
536-
found[str(path.relative_to(directory))] = path
560+
found[str(relative)] = path
537561
return found
538562

539563

@@ -574,20 +598,20 @@ def check_client_extensions(root: Path, report: Report) -> None:
574598
report.fail(
575599
f"{namespace}/{source}/{relative}",
576600
f"is missing from the client extension mirror of `{source}/`; "
577-
f"re-run scripts/add_client_extensions.py (§8.2).",
601+
f"refresh the `{namespace}/{source}/` mirror from the root copy (§8.2).",
578602
)
579603
for relative in sorted(set(mirrored) - set(original)):
580604
report.fail(
581605
f"{namespace}/{source}/{relative}",
582606
f"has no counterpart in the root `{source}/`; remove the stale "
583-
f"mirror (§8.2).",
607+
f"mirror file (§8.2).",
584608
)
585609
for relative in sorted(set(mirrored) & set(original)):
586610
if mirrored[relative].read_bytes() != original[relative].read_bytes():
587611
report.fail(
588612
f"{namespace}/{source}/{relative}",
589-
f"has drifted from the root `{source}/` copy; re-run "
590-
f"scripts/add_client_extensions.py (§8.2).",
613+
f"has drifted from the root `{source}/` copy; refresh the "
614+
f"mirror from the root copy (§8.2).",
591615
)
592616

593617
if mirrored:
@@ -631,7 +655,8 @@ def base(value):
631655
interface = other.get("interface")
632656
if isinstance(interface, dict):
633657
extensions = plugin.get("extensions")
634-
carried = extensions.get("com.openai", {}).get("interface") if isinstance(extensions, dict) else None
658+
openai = extensions.get("com.openai") if isinstance(extensions, dict) else None
659+
carried = openai.get("interface") if isinstance(openai, dict) else None
635660
if carried is None:
636661
report.fail(
637662
where,

0 commit comments

Comments
 (0)