mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-12 02:49:15 +08:00
912 lines
40 KiB
Python
912 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""Channel-aware Salesforce capability registry primitives.
|
|
|
|
The public release manifest is the only release-channel input to the checked
|
|
catalog. Internal authoring inventory is read only for an explicitly gated,
|
|
in-memory preview and is never serialized by this module.
|
|
|
|
Canonical tree hash policy (``sf-skill-tree-v1``): entries use sorted POSIX
|
|
relative paths. Directories, regular files, and symbolic links have distinct
|
|
record types. Regular-file records include a normalized executable boolean and
|
|
raw file bytes; permission bits other than any execute bit are ignored. Symlink
|
|
records include the raw link target bytes and no executable bit. Symlinks must
|
|
resolve to an existing target inside the declared safety root (the hashed tree
|
|
by default) and are not followed. Sockets, devices, FIFOs, and all other special
|
|
files are rejected.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import unicodedata
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
PUBLIC_MANIFEST_SCHEMA = "2.0"
|
|
PUBLIC_REPOSITORY = "https://github.com/forcedotcom/sf-skills.git"
|
|
RELEASE_REF_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+")
|
|
PUBLIC_MANIFEST_RELATIVE = Path("catalog/public-release-manifest.json")
|
|
TREE_HASH_FORMAT = b"sf-skill-tree-v1\0"
|
|
TREE_SCAN_MAX_ENTRIES = 4096
|
|
TREE_SCAN_MAX_DEPTH = 32
|
|
TREE_SCAN_MAX_FILE_BYTES = 8 * 1024 * 1024
|
|
TREE_SCAN_MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
|
TREE_SCAN_CHUNK_BYTES = 1024 * 1024
|
|
TREE_SCAN_DIR_FD_SUPPORTED = (
|
|
os.name != "nt"
|
|
and hasattr(os, "O_DIRECTORY")
|
|
and os.open in getattr(os, "supports_dir_fd", set())
|
|
)
|
|
NAME_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
|
APPROVED_DOMAIN_PREFIXES = (
|
|
"agentforce", "automation", "automotive-cloud", "channel-revenue-management",
|
|
"cme", "commerce", "communications", "consumer-goods", "crm-analytics",
|
|
"data360", "design-systems", "dx", "education-cloud", "energy-and-utilities",
|
|
"experience", "external", "field-service", "fsc", "health-cloud", "industries",
|
|
"insurance", "integration", "life-sciences", "manufacturing", "marketing",
|
|
"mobile", "net-zero", "non-profit", "omnistudio", "platform", "public-sector",
|
|
"revenue", "sales", "service", "sf-skill", "tableau", "tableau-next",
|
|
)
|
|
|
|
|
|
class RegistryError(ValueError):
|
|
"""A deterministic registry validation or generation error."""
|
|
|
|
|
|
def _tree_identity(value: os.stat_result) -> tuple[int, int, int, int, int, int, int]:
|
|
return (
|
|
value.st_dev, value.st_ino, value.st_mode, value.st_nlink, value.st_size,
|
|
value.st_mtime_ns, value.st_ctime_ns,
|
|
)
|
|
|
|
|
|
def read_regular_file_bytes(
|
|
path: Path,
|
|
*,
|
|
max_bytes: int = TREE_SCAN_MAX_FILE_BYTES,
|
|
expected: Optional[os.stat_result] = None,
|
|
expected_parent: Optional[os.stat_result] = None,
|
|
) -> bytes:
|
|
"""Read one stable, unlinked regular file through a verified parent directory."""
|
|
path = Path(path)
|
|
try:
|
|
before = path.lstat()
|
|
parent_before = path.parent.lstat()
|
|
except OSError as exc:
|
|
raise RegistryError(f"{path}: cannot inspect regular file: {exc}") from exc
|
|
if expected is not None and _tree_identity(expected) != _tree_identity(before):
|
|
raise RegistryError(f"{path}: regular file changed before read")
|
|
if (expected_parent is not None
|
|
and _tree_identity(expected_parent) != _tree_identity(parent_before)):
|
|
raise RegistryError(f"{path}: parent directory changed before read")
|
|
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
|
|
raise RegistryError(f"{path}: expected one non-hardlinked regular file")
|
|
flags = os.O_RDONLY
|
|
for optional in ("O_CLOEXEC", "O_NOFOLLOW", "O_NONBLOCK", "O_BINARY"):
|
|
flags |= getattr(os, optional, 0)
|
|
parent_descriptor: Optional[int] = None
|
|
descriptor: Optional[int] = None
|
|
try:
|
|
if TREE_SCAN_DIR_FD_SUPPORTED:
|
|
parent_flags = os.O_RDONLY | os.O_DIRECTORY
|
|
for optional in ("O_CLOEXEC", "O_NOFOLLOW"):
|
|
parent_flags |= getattr(os, optional, 0)
|
|
parent_descriptor = os.open(path.parent, parent_flags)
|
|
opened_parent = os.fstat(parent_descriptor)
|
|
if (_tree_identity(parent_before) != _tree_identity(opened_parent)
|
|
or not stat.S_ISDIR(opened_parent.st_mode)):
|
|
raise RegistryError(f"{path}: parent directory changed before read")
|
|
descriptor = os.open(path.name, flags, dir_fd=parent_descriptor)
|
|
else:
|
|
descriptor = os.open(path, flags)
|
|
except (OSError, RegistryError) as exc:
|
|
if parent_descriptor is not None:
|
|
os.close(parent_descriptor)
|
|
if isinstance(exc, RegistryError):
|
|
raise
|
|
raise RegistryError(f"{path}: cannot open regular file safely: {exc}") from exc
|
|
try:
|
|
opened = os.fstat(descriptor)
|
|
if (not stat.S_ISREG(opened.st_mode)
|
|
or opened.st_nlink != 1
|
|
or _tree_identity(before) != _tree_identity(opened)):
|
|
raise RegistryError(f"{path}: regular file changed before read")
|
|
if opened.st_size > max_bytes:
|
|
raise RegistryError(f"{path}: regular file byte limit exceeded")
|
|
chunks: list[bytes] = []
|
|
size = 0
|
|
while True:
|
|
chunk = os.read(descriptor, min(TREE_SCAN_CHUNK_BYTES, max_bytes + 1 - size))
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
size += len(chunk)
|
|
if size > max_bytes:
|
|
raise RegistryError(f"{path}: regular file byte limit exceeded")
|
|
finished = os.fstat(descriptor)
|
|
except OSError as exc:
|
|
raise RegistryError(f"{path}: cannot read regular file: {exc}") from exc
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
if parent_descriptor is not None:
|
|
os.close(parent_descriptor)
|
|
try:
|
|
current = path.lstat()
|
|
except OSError as exc:
|
|
raise RegistryError(f"{path}: regular file changed after read") from exc
|
|
if (_tree_identity(opened) != _tree_identity(finished)
|
|
or _tree_identity(finished) != _tree_identity(current)):
|
|
raise RegistryError(f"{path}: regular file changed during read")
|
|
return b"".join(chunks)
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
"""Hash one bounded, stable regular file as raw bytes."""
|
|
return hashlib.sha256(read_regular_file_bytes(path)).hexdigest()
|
|
|
|
|
|
def _hash_field(digest, value: bytes) -> None:
|
|
digest.update(len(value).to_bytes(8, "big"))
|
|
digest.update(value)
|
|
|
|
|
|
def inspect_skill_tree(
|
|
root: Path, *, safety_root: Optional[Path] = None,
|
|
budget: Optional[dict[str, int]] = None,
|
|
) -> dict:
|
|
"""Hash one tree and capture its SKILL.md bytes in the same bounded scan.
|
|
|
|
Runtime callers must derive trusted prose only from ``skillMdBytes``. Regular
|
|
files are opened no-follow/nonblocking where the host supports those flags,
|
|
verified before reading, and consumed within explicit byte budgets. A second
|
|
bounded inventory must match the first before captured prose is released.
|
|
"""
|
|
root = Path(root)
|
|
safety_root = Path(safety_root) if safety_root is not None else root
|
|
try:
|
|
if root.is_symlink() or not root.is_dir() or safety_root.is_symlink() or not safety_root.is_dir():
|
|
raise RegistryError(f"{root}: tree root and safety root must be real directories")
|
|
tree_anchor = root.resolve(strict=True)
|
|
anchor = safety_root.resolve(strict=True)
|
|
tree_anchor.relative_to(anchor)
|
|
root_metadata = root.lstat()
|
|
except (OSError, ValueError) as exc:
|
|
raise RegistryError(f"{root}: cannot resolve tree root inside safety root: {exc}") from exc
|
|
|
|
def inventory() -> list[tuple[str, Path, os.stat_result]]:
|
|
entries: list[tuple[str, Path, os.stat_result]] = []
|
|
|
|
def visit(directory: Path, depth: int) -> None:
|
|
if depth > TREE_SCAN_MAX_DEPTH:
|
|
raise RegistryError(f"{directory}: tree depth limit exceeded")
|
|
try:
|
|
children = os.scandir(directory)
|
|
except OSError as exc:
|
|
raise RegistryError(f"{directory}: cannot scan tree: {exc}") from exc
|
|
try:
|
|
for child in children:
|
|
if len(entries) >= TREE_SCAN_MAX_ENTRIES:
|
|
raise RegistryError(f"{root}: tree entry limit exceeded")
|
|
if budget is not None:
|
|
budget["entries"] = budget.get("entries", 0) + 1
|
|
if budget["entries"] > budget.get("maxEntries", TREE_SCAN_MAX_ENTRIES):
|
|
raise RegistryError(f"{root}: aggregate tree entry limit exceeded")
|
|
path = Path(child.path)
|
|
try:
|
|
metadata = path.lstat()
|
|
except OSError as exc:
|
|
raise RegistryError(f"{path}: cannot inspect tree entry: {exc}") from exc
|
|
relative = path.relative_to(root).as_posix()
|
|
entries.append((relative, path, metadata))
|
|
if stat.S_ISDIR(metadata.st_mode):
|
|
visit(path, depth + 1)
|
|
finally:
|
|
close = getattr(children, "close", None)
|
|
if close is not None:
|
|
close()
|
|
|
|
visit(root, 0)
|
|
return entries
|
|
|
|
entries = inventory()
|
|
directory_metadata = {".": root_metadata}
|
|
directory_metadata.update({
|
|
relative: metadata
|
|
for relative, _, metadata in entries
|
|
if stat.S_ISDIR(metadata.st_mode)
|
|
})
|
|
digest = hashlib.sha256()
|
|
digest.update(TREE_HASH_FORMAT)
|
|
skill_md_bytes: Optional[bytes] = None
|
|
stable = True
|
|
total_bytes = 0
|
|
for relative, path, metadata in sorted(entries, key=lambda item: item[0]):
|
|
relative_bytes = relative.encode("utf-8")
|
|
mode = metadata.st_mode
|
|
if stat.S_ISDIR(mode):
|
|
digest.update(b"D")
|
|
_hash_field(digest, relative_bytes)
|
|
elif stat.S_ISREG(mode):
|
|
if metadata.st_nlink != 1:
|
|
raise RegistryError(f"{path}: hardlinked tree files are not supported")
|
|
digest.update(b"F")
|
|
_hash_field(digest, relative_bytes)
|
|
digest.update(b"1" if mode & 0o111 else b"0")
|
|
flags = os.O_RDONLY
|
|
for optional in ("O_CLOEXEC", "O_NOFOLLOW", "O_NONBLOCK", "O_BINARY"):
|
|
flags |= getattr(os, optional, 0)
|
|
parent_descriptor: Optional[int] = None
|
|
descriptor: Optional[int] = None
|
|
parent_relative = Path(relative).parent.as_posix()
|
|
expected_parent = directory_metadata[parent_relative]
|
|
use_parent_fd = TREE_SCAN_DIR_FD_SUPPORTED
|
|
try:
|
|
if use_parent_fd:
|
|
parent_flags = os.O_RDONLY | os.O_DIRECTORY
|
|
for optional in ("O_CLOEXEC", "O_NOFOLLOW"):
|
|
parent_flags |= getattr(os, optional, 0)
|
|
parent_descriptor = os.open(path.parent, parent_flags)
|
|
opened_parent = os.fstat(parent_descriptor)
|
|
if (_tree_identity(expected_parent) != _tree_identity(opened_parent)
|
|
or not stat.S_ISDIR(opened_parent.st_mode)):
|
|
raise RegistryError(f"{path}: parent directory changed before read")
|
|
descriptor = os.open(path.name, flags, dir_fd=parent_descriptor)
|
|
else:
|
|
descriptor = os.open(path, flags)
|
|
except (OSError, RegistryError) as exc:
|
|
if parent_descriptor is not None:
|
|
os.close(parent_descriptor)
|
|
if isinstance(exc, RegistryError):
|
|
raise
|
|
raise RegistryError(f"{path}: cannot open parent directory or tree file safely: {exc}") from exc
|
|
try:
|
|
opened = os.fstat(descriptor)
|
|
if (not stat.S_ISREG(opened.st_mode)
|
|
or opened.st_nlink != 1
|
|
or _tree_identity(metadata) != _tree_identity(opened)):
|
|
raise RegistryError(f"{path}: tree file changed before read")
|
|
if opened.st_size > TREE_SCAN_MAX_FILE_BYTES:
|
|
raise RegistryError(f"{path}: tree file byte limit exceeded")
|
|
chunks: list[bytes] = []
|
|
file_bytes = 0
|
|
while True:
|
|
chunk = os.read(descriptor, TREE_SCAN_CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
file_bytes += len(chunk)
|
|
total_bytes += len(chunk)
|
|
if budget is not None:
|
|
budget["bytes"] = budget.get("bytes", 0) + len(chunk)
|
|
if budget["bytes"] > budget.get("maxBytes", TREE_SCAN_MAX_TOTAL_BYTES):
|
|
raise RegistryError(f"{root}: aggregate tree byte limit exceeded")
|
|
if file_bytes > TREE_SCAN_MAX_FILE_BYTES:
|
|
raise RegistryError(f"{path}: tree file byte limit exceeded")
|
|
if total_bytes > TREE_SCAN_MAX_TOTAL_BYTES:
|
|
raise RegistryError(f"{root}: tree total byte limit exceeded")
|
|
chunks.append(chunk)
|
|
content = b"".join(chunks)
|
|
finished = os.fstat(descriptor)
|
|
except OSError as exc:
|
|
raise RegistryError(f"{path}: cannot read tree file: {exc}") from exc
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
if parent_descriptor is not None:
|
|
os.close(parent_descriptor)
|
|
try:
|
|
current = path.lstat()
|
|
except OSError:
|
|
stable = False
|
|
else:
|
|
if (_tree_identity(opened) != _tree_identity(finished)
|
|
or _tree_identity(finished) != _tree_identity(current)):
|
|
stable = False
|
|
_hash_field(digest, content)
|
|
if relative == "SKILL.md":
|
|
skill_md_bytes = content
|
|
elif stat.S_ISLNK(mode):
|
|
try:
|
|
target_text = os.readlink(path)
|
|
resolved = path.resolve(strict=True)
|
|
resolved.relative_to(anchor)
|
|
except (OSError, ValueError) as exc:
|
|
raise RegistryError(f"{path}: unsafe, dangling, or out-of-root symlink") from exc
|
|
digest.update(b"L")
|
|
_hash_field(digest, relative_bytes)
|
|
_hash_field(digest, os.fsencode(target_text))
|
|
else:
|
|
raise RegistryError(f"{path}: special files are not supported in capability trees")
|
|
|
|
current_root: Optional[os.stat_result] = None
|
|
try:
|
|
current_root = root.lstat()
|
|
second = inventory()
|
|
except (OSError, RegistryError):
|
|
stable = False
|
|
second = []
|
|
first_fingerprint = [
|
|
(relative, _tree_identity(metadata))
|
|
for relative, _, metadata in sorted(entries, key=lambda item: item[0])
|
|
]
|
|
second_fingerprint = [
|
|
(relative, _tree_identity(metadata))
|
|
for relative, _, metadata in sorted(second, key=lambda item: item[0])
|
|
]
|
|
if (current_root is None
|
|
or _tree_identity(root_metadata) != _tree_identity(current_root)
|
|
or first_fingerprint != second_fingerprint):
|
|
stable = False
|
|
return {
|
|
"treeSha256": digest.hexdigest(),
|
|
"skillMdBytes": skill_md_bytes if stable else None,
|
|
"stable": stable,
|
|
}
|
|
|
|
|
|
def canonical_tree_sha256(root: Path, *, safety_root: Optional[Path] = None) -> str:
|
|
"""Return the canonical ``sf-skill-tree-v1`` hash for a directory tree."""
|
|
observation = inspect_skill_tree(root, safety_root=safety_root)
|
|
if not observation["stable"]:
|
|
raise RegistryError(f"{root}: tree changed during scan")
|
|
return observation["treeSha256"]
|
|
|
|
|
|
def _has_control(value: str) -> bool:
|
|
return any(unicodedata.category(char) in {"Cc", "Cf", "Zl", "Zp"} for char in value)
|
|
|
|
|
|
def _frontmatter_bytes(content: bytes, path: Path) -> list[str]:
|
|
try:
|
|
lines = content.decode("utf-8").splitlines()
|
|
except UnicodeError as exc:
|
|
raise RegistryError(f"{path}: cannot read SKILL.md: {exc}") from exc
|
|
if not lines or lines[0].strip() != "---":
|
|
raise RegistryError(f"{path}: missing opening frontmatter delimiter")
|
|
try:
|
|
end = next(index for index in range(1, len(lines)) if lines[index].strip() == "---")
|
|
except StopIteration as exc:
|
|
raise RegistryError(f"{path}: missing closing frontmatter delimiter") from exc
|
|
return lines[1:end]
|
|
|
|
|
|
def _frontmatter(path: Path) -> list[str]:
|
|
return _frontmatter_bytes(read_regular_file_bytes(path), path)
|
|
|
|
|
|
def _block_scalar(lines: list[str], start: int, style: str, path: Path) -> str:
|
|
values: list[Optional[str]] = []
|
|
for line in lines[start + 1:]:
|
|
if line and not line[0].isspace():
|
|
break
|
|
if not line.strip():
|
|
values.append(None)
|
|
else:
|
|
match = re.match(r"^(\s+)(.*)$", line)
|
|
if not match:
|
|
raise RegistryError(f"{path}: malformed description block")
|
|
values.append(match.group(2))
|
|
if not values or not any(value is not None for value in values):
|
|
raise RegistryError(f"{path}: description block is empty")
|
|
if style.startswith("|"):
|
|
text = "\n".join("" if value is None else value for value in values)
|
|
else:
|
|
paragraphs: list[str] = []
|
|
current: list[str] = []
|
|
for value in values:
|
|
if value is None:
|
|
if current:
|
|
paragraphs.append(" ".join(current))
|
|
current = []
|
|
else:
|
|
current.append(value)
|
|
if current:
|
|
paragraphs.append(" ".join(current))
|
|
text = "\n".join(paragraphs)
|
|
return text + "\n" if style.endswith("+") or style in (">", "|") else text
|
|
|
|
|
|
def read_skill_bytes(content: bytes, path: Path) -> dict[str, str]:
|
|
"""Parse the bounded name and description subset from captured SKILL.md bytes."""
|
|
lines = _frontmatter_bytes(content, path)
|
|
fields: dict[str, str] = {}
|
|
for index, line in enumerate(lines):
|
|
if not line or line[0].isspace() or ":" not in line:
|
|
continue
|
|
key, raw = line.split(":", 1)
|
|
if key not in ("name", "description"):
|
|
continue
|
|
value = raw.strip()
|
|
if key == "description" and value in (">", ">-", ">+", "|", "|-", "|+"):
|
|
fields[key] = _block_scalar(lines, index, value, path)
|
|
elif value.startswith('"'):
|
|
try:
|
|
parsed = json.loads(value)
|
|
except json.JSONDecodeError as exc:
|
|
raise RegistryError(f"{path}: invalid double-quoted {key}: {exc.msg}") from exc
|
|
if type(parsed) is not str:
|
|
raise RegistryError(f"{path}: {key} must be a string")
|
|
fields[key] = parsed
|
|
elif key == "name" and NAME_PATTERN.fullmatch(value):
|
|
fields[key] = value
|
|
else:
|
|
raise RegistryError(f"{path}: unsupported {key} scalar")
|
|
if set(fields) != {"name", "description"}:
|
|
raise RegistryError(f"{path}: missing required name or description")
|
|
if fields["name"] != path.parent.name:
|
|
raise RegistryError(f"{path}: frontmatter name does not match directory")
|
|
if len(fields["name"]) > 64 or not 1 <= len(fields["description"]) <= 1024:
|
|
raise RegistryError(f"{path}: name or description is outside supported bounds")
|
|
if _has_control(fields["name"]) or _has_control(fields["description"]):
|
|
raise RegistryError(f"{path}: name or description contains control characters")
|
|
return fields
|
|
|
|
|
|
def read_skill(path: Path) -> dict[str, str]:
|
|
"""Read the bounded name and description subset from SKILL.md frontmatter."""
|
|
return read_skill_bytes(read_regular_file_bytes(path), path)
|
|
|
|
|
|
def _access_scalar(raw: str, path: Path) -> str:
|
|
"""Parse a single accessCheck ``type``/``value`` scalar (quoted or bare)."""
|
|
if raw.startswith('"'):
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise RegistryError(f"{path}: invalid quoted accessCheck scalar: {exc.msg}") from exc
|
|
if type(parsed) is not str:
|
|
raise RegistryError(f"{path}: accessCheck scalar must be a string")
|
|
return parsed
|
|
return raw
|
|
|
|
|
|
def read_access_check(path: Path) -> Optional[list[dict[str, str]]]:
|
|
"""Read the tri-state ``metadata.accessCheck`` from SKILL.md frontmatter.
|
|
|
|
Returns ``None`` when accessCheck is undeclared (no ``metadata`` block or no
|
|
``accessCheck`` key), ``[]`` for an explicit empty array (applies to any
|
|
org), or a list of ``{"type", "value"}`` entries when availability is
|
|
conditional. Raises RegistryError on a present-but-malformed declaration so a
|
|
broken accessCheck can never silently collapse into "undeclared" or "any
|
|
org". Bounded hand parser (this module intentionally avoids a YAML
|
|
dependency, matching ``read_skill``); shape is enforced by
|
|
``_valid_access_check``. Only inline ``[]`` / double-quoted JSON arrays and
|
|
block-style ``- type:``/``value:`` entries are recognized; any other form
|
|
fails loud.
|
|
"""
|
|
lines = _frontmatter(path)
|
|
meta_index = next(
|
|
(index for index, line in enumerate(lines)
|
|
if line and not line[0].isspace() and line.split(":", 1)[0].strip() == "metadata"),
|
|
None,
|
|
)
|
|
if meta_index is None:
|
|
return None
|
|
block = []
|
|
for line in lines[meta_index + 1:]:
|
|
if line and not line[0].isspace():
|
|
break
|
|
block.append(line)
|
|
ac_index = ac_indent = None
|
|
inline = ""
|
|
for index, line in enumerate(block):
|
|
if not line.strip():
|
|
continue
|
|
if line.split(":", 1)[0].strip() == "accessCheck":
|
|
ac_index = index
|
|
ac_indent = len(line) - len(line.lstrip())
|
|
inline = line.split(":", 1)[1].strip() if ":" in line else ""
|
|
break
|
|
if ac_index is None:
|
|
return None
|
|
if inline:
|
|
try:
|
|
parsed = json.loads(inline)
|
|
except json.JSONDecodeError as exc:
|
|
raise RegistryError(f"{path}: unsupported accessCheck value: {exc.msg}") from exc
|
|
if type(parsed) is not list:
|
|
raise RegistryError(f"{path}: accessCheck must be an array")
|
|
return parsed
|
|
entries: list[dict[str, str]] = []
|
|
current: Optional[dict[str, str]] = None
|
|
for line in block[ac_index + 1:]:
|
|
if not line.strip():
|
|
continue
|
|
if len(line) - len(line.lstrip()) <= ac_indent:
|
|
break
|
|
stripped = line.strip()
|
|
if stripped.startswith("-"):
|
|
current = {}
|
|
entries.append(current)
|
|
stripped = stripped[1:].strip()
|
|
if not stripped:
|
|
continue
|
|
if current is None or ":" not in stripped:
|
|
raise RegistryError(f"{path}: malformed accessCheck entry")
|
|
key, raw = stripped.split(":", 1)
|
|
current[key.strip()] = _access_scalar(raw.strip(), path)
|
|
if not entries:
|
|
raise RegistryError(f"{path}: accessCheck is present but empty; use [] for any-org")
|
|
return entries
|
|
|
|
|
|
EXCLUSION_CLAUSE = re.compile(
|
|
r"\b(?:do\s+not\s+trigger|do\s+not\s+use|not\s+for|skip\s+when|does\s+not\s+apply)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
USER_INTENT_VERBS = {
|
|
"add", "analyze", "apply", "assign", "audit", "build", "check", "configure",
|
|
"connect", "create", "debug", "deploy", "enable", "find", "generate", "get",
|
|
"help", "integrate", "migrate", "open", "query", "replace", "retrieve", "run",
|
|
"review", "scan", "score", "search", "secure", "set", "ship", "show", "switch",
|
|
"test", "validate", "verify",
|
|
}
|
|
CURATED_EXAMPLES = {
|
|
"agentforce-generate": "Build an Agentforce agent for order-status help.",
|
|
"data360-connect": "Connect a data stream from my order system.",
|
|
"platform-apex-generate": "Create an Apex service to query Accounts.",
|
|
"platform-apex-test-generate": "Generate Apex tests for my selector class.",
|
|
"platform-custom-object-generate": "Create a custom object for service visits.",
|
|
"platform-deploy-validate": "Validate this deployment before I ship it.",
|
|
"platform-environment-validate": "Check whether my environment is ready to build.",
|
|
"platform-metadata-deploy": "Deploy my local changes to the scratch org.",
|
|
"platform-soql-query": "Query the ten largest open opportunities.",
|
|
}
|
|
|
|
|
|
def is_user_prompt_like(phrase: str) -> bool:
|
|
if not phrase or "\n" in phrase or len(phrase) > 140:
|
|
return False
|
|
if re.search(r"[<>/\\`{}\[\]]|__|\.[A-Za-z0-9]", phrase):
|
|
return False
|
|
words = re.findall(r"[A-Za-z][A-Za-z'-]*", phrase)
|
|
return bool(
|
|
len(words) >= 2
|
|
and (len(words[0]) != 1 or words[0].lower() == "i")
|
|
and words[0].lower() in USER_INTENT_VERBS | {"how", "i", "what", "when", "where", "why"}
|
|
)
|
|
|
|
|
|
def example_prompt(name: str, description: str, domain: str) -> str:
|
|
"""Freeze a bounded display prompt while trusted source prose is in hand."""
|
|
if name in CURATED_EXAMPLES:
|
|
return CURATED_EXAMPLES[name]
|
|
for trigger in re.finditer(r"\btriggers?\b|\buse when\b", description, re.IGNORECASE):
|
|
prefix = description[max(0, trigger.start() - 24):trigger.start()]
|
|
if re.search(r"\bdo\s+not\s+$", prefix, re.IGNORECASE):
|
|
continue
|
|
tail = EXCLUSION_CLAUSE.split(description[trigger.end():], maxsplit=1)[0]
|
|
for match in re.finditer(r"['\"]([^'\"\n]{4,140})['\"]", tail):
|
|
phrase = match.group(1).strip()
|
|
if is_user_prompt_like(phrase):
|
|
return phrase[0].upper() + phrase[1:]
|
|
remainder = name[len(domain):].strip("-")
|
|
parts = remainder.split("-") if remainder else []
|
|
verb = parts[-1] if parts else "use"
|
|
subject = " ".join(parts[:-1]) or domain.replace("-", " ")
|
|
return f"Help me {verb} Salesforce {subject}."
|
|
|
|
|
|
def derive_domain(name: str) -> str:
|
|
matches = [prefix for prefix in APPROVED_DOMAIN_PREFIXES if name == prefix or name.startswith(prefix + "-")]
|
|
if not matches:
|
|
raise RegistryError(f"skill {name!r}: no approved domain prefix")
|
|
return max(matches, key=len)
|
|
|
|
|
|
def skill_directories(root: Path) -> dict[str, Path]:
|
|
"""Return strict one-level skill directories keyed by validated name."""
|
|
if not root.is_dir():
|
|
raise RegistryError(f"{root}: skills directory is missing")
|
|
result: dict[str, Path] = {}
|
|
for entry in sorted(root.iterdir(), key=lambda item: item.name):
|
|
if not entry.is_dir() or entry.is_symlink():
|
|
raise RegistryError(f"{entry}: skills inventory must contain only real directories")
|
|
if not NAME_PATTERN.fullmatch(entry.name):
|
|
raise RegistryError(f"{entry}: invalid skill directory name")
|
|
skill_file = entry / "SKILL.md"
|
|
record = read_skill(skill_file)
|
|
if record["name"] != entry.name:
|
|
raise RegistryError(f"{skill_file}: inventory name mismatch")
|
|
result[entry.name] = entry
|
|
return result
|
|
|
|
|
|
def source_variant(
|
|
skill_dir: Path, *, safety_root: Optional[Path] = None
|
|
) -> dict[str, str]:
|
|
record = read_skill(skill_dir / "SKILL.md")
|
|
return {
|
|
"description": record["description"],
|
|
"skillMdSha256": sha256_file(skill_dir / "SKILL.md"),
|
|
"treeSha256": canonical_tree_sha256(skill_dir, safety_root=safety_root),
|
|
}
|
|
|
|
|
|
def normalize_public_repository(origin: str) -> str:
|
|
"""Return the canonical public identity for accepted GitHub origin forms.
|
|
|
|
Error text deliberately never includes the supplied origin because HTTPS
|
|
remotes may contain credentials.
|
|
"""
|
|
accepted = False
|
|
if re.fullmatch(r"git@github\.com:forcedotcom/sf-skills(?:\.git)?", origin):
|
|
accepted = True
|
|
else:
|
|
try:
|
|
parsed = urlsplit(origin)
|
|
path = unquote(parsed.path).rstrip("/")
|
|
if path.endswith(".git"):
|
|
path = path[:-4]
|
|
if parsed.scheme == "https":
|
|
accepted = (
|
|
parsed.hostname == "github.com"
|
|
and parsed.port in (None, 443)
|
|
and path == "/forcedotcom/sf-skills"
|
|
and not parsed.query
|
|
and not parsed.fragment
|
|
)
|
|
elif parsed.scheme == "ssh":
|
|
accepted = (
|
|
parsed.hostname == "github.com"
|
|
and parsed.port in (None, 22)
|
|
and parsed.username == "git"
|
|
and parsed.password is None
|
|
and path == "/forcedotcom/sf-skills"
|
|
and not parsed.query
|
|
and not parsed.fragment
|
|
)
|
|
except (TypeError, ValueError):
|
|
accepted = False
|
|
if not accepted:
|
|
raise RegistryError("public checkout has an unsupported repository origin")
|
|
return PUBLIC_REPOSITORY
|
|
|
|
|
|
def _git(checkout: Path, *args: str) -> str:
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "-C", str(checkout), *args], capture_output=True, text=True,
|
|
timeout=30, check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise RegistryError(f"{checkout}: cannot inspect public git checkout") from exc
|
|
if result.returncode != 0:
|
|
raise RegistryError(f"{checkout}: public checkout git metadata is unavailable")
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _git_paths(checkout: Path, *args: str) -> set[str]:
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "-C", str(checkout), *args],
|
|
capture_output=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise RegistryError(f"{checkout}: cannot inspect public git checkout") from exc
|
|
if result.returncode != 0:
|
|
raise RegistryError(f"{checkout}: public checkout git metadata is unavailable")
|
|
return {
|
|
os.fsdecode(value)
|
|
for value in result.stdout.split(b"\0")
|
|
if value
|
|
}
|
|
|
|
|
|
def _validate_tracked_skills_tree(checkout: Path) -> None:
|
|
"""Require every filesystem entry under ``skills/`` to exist in Git's tree."""
|
|
tracked = _git_paths(checkout, "ls-files", "-z", "--cached", "--", "skills")
|
|
tracked_directories = {
|
|
parent.as_posix()
|
|
for path in tracked
|
|
for parent in Path(path).parents
|
|
if parent.as_posix() not in (".", "")
|
|
}
|
|
actual_files: set[str] = set()
|
|
actual_directories: set[str] = set()
|
|
|
|
def visit(directory: Path) -> None:
|
|
try:
|
|
children = list(os.scandir(directory))
|
|
except OSError as exc:
|
|
raise RegistryError(f"{directory}: cannot validate tracked git tree") from exc
|
|
for child in children:
|
|
path = Path(child.path)
|
|
relative = path.relative_to(checkout).as_posix()
|
|
try:
|
|
mode = path.lstat().st_mode
|
|
except OSError as exc:
|
|
raise RegistryError(f"{path}: cannot validate tracked git tree") from exc
|
|
if stat.S_ISDIR(mode):
|
|
actual_directories.add(relative)
|
|
visit(path)
|
|
else:
|
|
actual_files.add(relative)
|
|
|
|
skills = checkout / "skills"
|
|
if not skills.is_dir() or skills.is_symlink():
|
|
raise RegistryError(f"{skills}: skills root is not a tracked git tree")
|
|
visit(skills)
|
|
if actual_files != tracked or not actual_directories.issubset(tracked_directories):
|
|
raise RegistryError(f"{skills}: filesystem entries must exactly match the tracked git tree")
|
|
|
|
|
|
def build_public_manifest(checkout: Path, release_ref: str) -> dict:
|
|
"""Build a path-free public release manifest from an exact tagged checkout."""
|
|
checkout = Path(checkout)
|
|
commit = _git(checkout, "rev-parse", "HEAD")
|
|
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
|
raise RegistryError(f"{checkout}: public commit is not a full SHA")
|
|
if type(release_ref) is not str or not RELEASE_REF_PATTERN.fullmatch(release_ref):
|
|
raise RegistryError("public release ref must be a numeric three-part release tag")
|
|
tagged_commit = _git(checkout, "rev-parse", "--verify", f"refs/tags/{release_ref}^{{commit}}")
|
|
if tagged_commit != commit:
|
|
raise RegistryError("public release ref does not resolve to the recorded commit")
|
|
repository = normalize_public_repository(_git(checkout, "remote", "get-url", "origin"))
|
|
if _git(checkout, "status", "--porcelain", "--untracked-files=all"):
|
|
raise RegistryError(f"{checkout}: public checkout must be clean at the recorded commit")
|
|
_validate_tracked_skills_tree(checkout)
|
|
inventory = skill_directories(checkout / "skills")
|
|
rows = []
|
|
for name, skill_dir in inventory.items():
|
|
record = read_skill(skill_dir / "SKILL.md")
|
|
domain = derive_domain(name)
|
|
prompt = example_prompt(name, record["description"], domain)
|
|
if not is_user_prompt_like(prompt) or _has_control(prompt):
|
|
raise RegistryError(f"{skill_dir}: cannot freeze a safe example prompt")
|
|
rows.append({
|
|
"name": name,
|
|
"domain": domain,
|
|
"examplePrompt": prompt,
|
|
"skillMdSha256": sha256_file(skill_dir / "SKILL.md"),
|
|
"treeSha256": canonical_tree_sha256(skill_dir),
|
|
"accessCheck": read_access_check(skill_dir / "SKILL.md"),
|
|
})
|
|
data = {
|
|
"schemaVersion": PUBLIC_MANIFEST_SCHEMA,
|
|
"channel": "public-release",
|
|
"repository": repository,
|
|
"commit": commit,
|
|
"releaseRef": release_ref,
|
|
"counts": {"public": len(rows)},
|
|
"skills": rows,
|
|
}
|
|
validate_public_manifest(data, "generated public release manifest")
|
|
return data
|
|
|
|
|
|
_MANIFEST_TOP_KEYS = {"schemaVersion", "channel", "repository", "commit", "releaseRef", "counts", "skills"}
|
|
_MANIFEST_ROW_KEYS = {"name", "domain", "examplePrompt", "skillMdSha256", "treeSha256", "accessCheck"}
|
|
_ACCESS_CHECK_TYPES = {"license", "userPerm", "orgPerm", "orgPref", "accessCheck"}
|
|
|
|
|
|
def _valid_hash(value) -> bool:
|
|
return type(value) is str and re.fullmatch(r"[0-9a-f]{64}", value) is not None
|
|
|
|
|
|
def _valid_access_check(value) -> bool:
|
|
"""Validate the tri-state accessCheck: ``None`` (undeclared) or a list of
|
|
``{type, value}`` entries (``[]`` = any org). Mirrors the canonical schema in
|
|
scripts/validate-skills.ts exactly: ``type`` in the fixed enum, ``value`` any
|
|
string (no emptiness or control-character constraint), exact ``{type, value}``
|
|
keys. ``None`` and ``[]`` are kept distinct — never collapsed."""
|
|
if value is None:
|
|
return True
|
|
if type(value) is not list:
|
|
return False
|
|
return all(
|
|
type(entry) is dict and set(entry) == {"type", "value"}
|
|
and entry["type"] in _ACCESS_CHECK_TYPES and type(entry["value"]) is str
|
|
for entry in value
|
|
)
|
|
|
|
|
|
def validate_public_manifest(data, context: str) -> None:
|
|
if type(data) is not dict or set(data) != _MANIFEST_TOP_KEYS:
|
|
raise RegistryError(f"{context}: invalid top-level public manifest keys")
|
|
if data["schemaVersion"] != PUBLIC_MANIFEST_SCHEMA or data["channel"] != "public-release":
|
|
raise RegistryError(f"{context}: unsupported public manifest schema or channel")
|
|
if data["repository"] != PUBLIC_REPOSITORY or not re.fullmatch(r"[0-9a-f]{40}", data["commit"] or ""):
|
|
raise RegistryError(f"{context}: invalid public repository or commit")
|
|
if type(data["releaseRef"]) is not str or not RELEASE_REF_PATTERN.fullmatch(data["releaseRef"]):
|
|
raise RegistryError(f"{context}: invalid public release ref")
|
|
if type(data["counts"]) is not dict or set(data["counts"]) != {"public"} or type(data["counts"]["public"]) is not int:
|
|
raise RegistryError(f"{context}: invalid public manifest counts")
|
|
if type(data["skills"]) is not list or data["counts"]["public"] != len(data["skills"]):
|
|
raise RegistryError(f"{context}: public skill count mismatch")
|
|
names: list[str] = []
|
|
for index, row in enumerate(data["skills"]):
|
|
row_context = f"{context}: public skill row {index}"
|
|
if type(row) is not dict or set(row) != _MANIFEST_ROW_KEYS:
|
|
raise RegistryError(f"{row_context}: invalid keys")
|
|
name = row["name"]
|
|
if type(name) is not str or not NAME_PATTERN.fullmatch(name) or len(name) > 64:
|
|
raise RegistryError(f"{row_context}: invalid name")
|
|
if row["domain"] != derive_domain(name):
|
|
raise RegistryError(f"{row_context}: invalid domain")
|
|
prompt = row["examplePrompt"]
|
|
if (type(prompt) is not str or not 1 <= len(prompt) <= 140
|
|
or _has_control(prompt) or not is_user_prompt_like(prompt)):
|
|
raise RegistryError(f"{row_context}: invalid example prompt")
|
|
if not _valid_hash(row["skillMdSha256"]) or not _valid_hash(row["treeSha256"]):
|
|
raise RegistryError(f"{row_context}: invalid content hash")
|
|
if not _valid_access_check(row["accessCheck"]):
|
|
raise RegistryError(f"{row_context}: invalid accessCheck")
|
|
names.append(name)
|
|
if names != sorted(names) or len(names) != len(set(names)):
|
|
raise RegistryError(f"{context}: public skill names must be unique and sorted")
|
|
|
|
|
|
def serialize(data: dict) -> str:
|
|
return json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
|
|
|
|
def load_public_manifest_observation(path: Path) -> tuple[dict, bytes]:
|
|
"""Load and validate a manifest while retaining the exact verified bytes."""
|
|
try:
|
|
content = read_regular_file_bytes(Path(path), max_bytes=16 * 1024 * 1024)
|
|
data = json.loads(content.decode("utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
raise RegistryError(f"{path}: cannot load public release manifest: {exc}") from exc
|
|
validate_public_manifest(data, str(path))
|
|
return data, content
|
|
|
|
|
|
def load_public_manifest(path: Path) -> dict:
|
|
return load_public_manifest_observation(path)[0]
|
|
|
|
|
|
def snapshot_public(checkout: Path, destination: Path, release_ref: str) -> Path:
|
|
data = build_public_manifest(checkout, release_ref)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_text(serialize(data), encoding="utf-8")
|
|
return destination
|
|
|
|
|
|
def check_public(checkout: Path, destination: Path, release_ref: str) -> bool:
|
|
try:
|
|
actual = read_regular_file_bytes(destination, max_bytes=16 * 1024 * 1024).decode("utf-8")
|
|
except (OSError, UnicodeError, RegistryError) as exc:
|
|
raise RegistryError(f"{destination}: public manifest is missing: {exc}") from exc
|
|
expected = serialize(build_public_manifest(checkout, release_ref))
|
|
if actual != expected:
|
|
raise RegistryError(f"{destination}: public release manifest is stale")
|
|
return True
|
|
|
|
|
|
def main(argv: Optional[list[str]] = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
modes = parser.add_mutually_exclusive_group(required=True)
|
|
modes.add_argument("--snapshot-public", action="store_true")
|
|
modes.add_argument("--check-public", action="store_true")
|
|
parser.add_argument("--checkout", type=Path, required=True)
|
|
parser.add_argument("--release-ref", required=True)
|
|
parser.add_argument("--output", type=Path)
|
|
options = parser.parse_args(argv)
|
|
plugin_root = Path(__file__).resolve().parent.parent
|
|
destination = options.output or plugin_root / PUBLIC_MANIFEST_RELATIVE
|
|
try:
|
|
if options.snapshot_public:
|
|
snapshot_public(options.checkout, destination, options.release_ref)
|
|
print(f"generated public release manifest: {destination}")
|
|
else:
|
|
check_public(options.checkout, destination, options.release_ref)
|
|
print(f"public release manifest is current: {destination}")
|
|
except RegistryError as exc:
|
|
print(f"capability registry error: {exc}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|