mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-06 15:59:59 +08:00
313 lines
13 KiB
Python
313 lines
13 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Guarded deterministic installer for explicitly authorized internal previews."""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import ast
|
||
|
|
import importlib.util
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Callable, Mapping, Optional
|
||
|
|
|
||
|
|
try:
|
||
|
|
import capability_registry as registry
|
||
|
|
except ImportError:
|
||
|
|
module_path = Path(__file__).resolve().parent / "capability_registry.py"
|
||
|
|
spec = importlib.util.spec_from_file_location("internal_preview_capability_registry", module_path)
|
||
|
|
if spec is None or spec.loader is None:
|
||
|
|
raise
|
||
|
|
registry = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(registry)
|
||
|
|
|
||
|
|
INTERNAL_NOTICE = "INTERNAL PREVIEW — not publicly supported"
|
||
|
|
SOURCE_CHANNEL = "internal-preview"
|
||
|
|
CLI_VERSION = "skills@1.5.20"
|
||
|
|
DEFAULT_TIMEOUT_SECONDS = 120
|
||
|
|
MAX_STREAM_BYTES = 4000
|
||
|
|
# Trust boundary (#1030 adversarial review, finding 4): the npm_config_registry /
|
||
|
|
# npm_config_ca(file) keys below are forwarded on purpose so an internal user behind
|
||
|
|
# a corporate proxy/registry can install. The consequence is that the pinned
|
||
|
|
# `skills@1.5.20` spec carries no lockfile/integrity hash, so `npx` re-resolves it
|
||
|
|
# through whatever registry the environment points at — the version pin is an
|
||
|
|
# ergonomic default, NOT a supply-chain integrity guarantee. The post-install
|
||
|
|
# canonical-tree hash check (see _destination_hash / the postcondition below) rejects
|
||
|
|
# tampered *skill content*, but it does NOT constrain what the fetched CLI executes at
|
||
|
|
# install time; arbitrary code execution at install time is inherent to running any
|
||
|
|
# `npx` package and is out of scope for this gate. Do not read the pin+hash as
|
||
|
|
# defense against a hostile registry.
|
||
|
|
_SUBPROCESS_ENV_KEYS = {
|
||
|
|
"PATH", "HOME", "USERPROFILE", "TMPDIR", "TMP", "TEMP",
|
||
|
|
"XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
|
||
|
|
"npm_config_cache", "npm_config_prefix", "npm_config_userconfig", "npm_config_tmp",
|
||
|
|
"NPM_CONFIG_CACHE", "NPM_CONFIG_PREFIX", "NPM_CONFIG_USERCONFIG", "NPM_CONFIG_TMP",
|
||
|
|
"npm_config_registry", "NPM_CONFIG_REGISTRY", "npm_config_ca", "NPM_CONFIG_CA",
|
||
|
|
"npm_config_cafile", "NPM_CONFIG_CAFILE", "LANG", "COMSPEC", "SystemRoot",
|
||
|
|
"SYSTEMROOT", "PATHEXT",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class InstallerError(ValueError):
|
||
|
|
"""A safe, non-sensitive installer denial or postcondition failure."""
|
||
|
|
|
||
|
|
|
||
|
|
def _base(name: object) -> dict:
|
||
|
|
return {
|
||
|
|
"notice": INTERNAL_NOTICE,
|
||
|
|
"name": name if isinstance(name, str) and registry.NAME_PATTERN.fullmatch(name) else None,
|
||
|
|
"sourceChannel": SOURCE_CHANNEL,
|
||
|
|
"freshSessionRequired": True,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _error(name: object, message: str, *, execution: Optional[dict] = None) -> tuple[int, dict]:
|
||
|
|
result = {**_base(name), "status": "error", "message": message}
|
||
|
|
if execution is not None:
|
||
|
|
result["execution"] = execution
|
||
|
|
return 1, result
|
||
|
|
|
||
|
|
|
||
|
|
def _read_holds(path: Path, authoring_names: set[str]) -> set[str]:
|
||
|
|
try:
|
||
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||
|
|
except (OSError, UnicodeError) as exc:
|
||
|
|
raise InstallerError("internal checkout is invalid") from exc
|
||
|
|
for index, line in enumerate(lines):
|
||
|
|
stripped = line.strip()
|
||
|
|
if not stripped.startswith("internal:"):
|
||
|
|
continue
|
||
|
|
value = stripped.split(":", 1)[1].strip()
|
||
|
|
if value:
|
||
|
|
try:
|
||
|
|
parsed = ast.literal_eval(value)
|
||
|
|
except (SyntaxError, ValueError) as exc:
|
||
|
|
raise InstallerError("internal checkout is invalid") from exc
|
||
|
|
if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed):
|
||
|
|
raise InstallerError("internal checkout is invalid")
|
||
|
|
held = set(parsed)
|
||
|
|
else:
|
||
|
|
held = set()
|
||
|
|
for child in lines[index + 1:]:
|
||
|
|
if child and not child[0].isspace():
|
||
|
|
break
|
||
|
|
match = re.match(r"^\s+-\s+(['\"]?)([a-z0-9-]+)\1\s*$", child)
|
||
|
|
if child.strip() and not match:
|
||
|
|
raise InstallerError("internal checkout is invalid")
|
||
|
|
if match:
|
||
|
|
held.add(match.group(2))
|
||
|
|
if any(not registry.NAME_PATTERN.fullmatch(item) for item in held) or held - authoring_names:
|
||
|
|
raise InstallerError("internal checkout is invalid")
|
||
|
|
return held
|
||
|
|
raise InstallerError("internal checkout is invalid")
|
||
|
|
|
||
|
|
|
||
|
|
def _validate_checkout(repo_root: Path, plugin_root: Path) -> tuple[Path, Path, dict[str, Path], dict[str, Path], dict[str, dict], set[str]]:
|
||
|
|
try:
|
||
|
|
repo = Path(repo_root).resolve(strict=True)
|
||
|
|
plugin = Path(plugin_root).resolve(strict=True)
|
||
|
|
plugin.relative_to(repo)
|
||
|
|
except (OSError, ValueError) as exc:
|
||
|
|
raise InstallerError("internal checkout is invalid") from exc
|
||
|
|
config = repo / "config.yml"
|
||
|
|
skills = repo / "skills"
|
||
|
|
if config.is_symlink() or not config.is_file() or skills.is_symlink() or not skills.is_dir():
|
||
|
|
raise InstallerError("internal checkout is invalid")
|
||
|
|
try:
|
||
|
|
authoring = registry.skill_directories(skills)
|
||
|
|
foundation = registry.skill_directories(plugin / "skills")
|
||
|
|
manifest = registry.load_public_manifest(plugin / registry.PUBLIC_MANIFEST_RELATIVE)
|
||
|
|
public = {row["name"]: row for row in manifest["skills"]}
|
||
|
|
held = _read_holds(config, set(authoring))
|
||
|
|
except (OSError, registry.RegistryError, InstallerError) as exc:
|
||
|
|
raise InstallerError("internal checkout is invalid") from exc
|
||
|
|
return repo, plugin, authoring, foundation, public, held
|
||
|
|
|
||
|
|
|
||
|
|
def _validate_project(cwd: Path) -> Path:
|
||
|
|
raw = Path(cwd).absolute()
|
||
|
|
try:
|
||
|
|
if raw.is_symlink():
|
||
|
|
raise InstallerError("current directory is not a valid Salesforce project")
|
||
|
|
project = raw.resolve(strict=True)
|
||
|
|
except OSError as exc:
|
||
|
|
raise InstallerError("current directory is not a valid Salesforce project") from exc
|
||
|
|
marker = project / "sfdx-project.json"
|
||
|
|
if not project.is_dir() or marker.is_symlink() or not marker.is_file():
|
||
|
|
raise InstallerError("current directory is not a valid Salesforce project")
|
||
|
|
return project
|
||
|
|
|
||
|
|
|
||
|
|
def _inspect_project_path(path: Path, project: Path) -> bool:
|
||
|
|
"""Validate one existing project path without following its final component."""
|
||
|
|
try:
|
||
|
|
stat = path.lstat()
|
||
|
|
except FileNotFoundError:
|
||
|
|
return False
|
||
|
|
except OSError as exc:
|
||
|
|
raise InstallerError("project destination is unsafe") from exc
|
||
|
|
if path.is_symlink() or not path.is_dir():
|
||
|
|
raise InstallerError("project destination is unsafe")
|
||
|
|
try:
|
||
|
|
path.resolve(strict=True).relative_to(project)
|
||
|
|
except (OSError, ValueError) as exc:
|
||
|
|
raise InstallerError("project destination is unsafe") from exc
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def _inspect_destination_chain(project: Path, destination: Path) -> bool:
|
||
|
|
_inspect_project_path(project, project)
|
||
|
|
_inspect_project_path(project / ".claude", project)
|
||
|
|
_inspect_project_path(project / ".claude" / "skills", project)
|
||
|
|
return _inspect_project_path(destination, project)
|
||
|
|
|
||
|
|
|
||
|
|
def _stream_bytes(value: object) -> int:
|
||
|
|
if value is None:
|
||
|
|
return 0
|
||
|
|
if isinstance(value, bytes):
|
||
|
|
return len(value)
|
||
|
|
if isinstance(value, str):
|
||
|
|
return len(value.encode("utf-8", errors="replace"))
|
||
|
|
try:
|
||
|
|
return len(str(value).encode("utf-8", errors="replace"))
|
||
|
|
except Exception:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def _execution(returncode: Optional[int], stdout: object, stderr: object, *, timed_out: bool) -> dict:
|
||
|
|
stdout_bytes = _stream_bytes(stdout)
|
||
|
|
stderr_bytes = _stream_bytes(stderr)
|
||
|
|
return {
|
||
|
|
"exitCode": returncode,
|
||
|
|
"timedOut": timed_out,
|
||
|
|
"stdoutBytes": stdout_bytes,
|
||
|
|
"stdoutTruncated": stdout_bytes > MAX_STREAM_BYTES,
|
||
|
|
"stderrBytes": stderr_bytes,
|
||
|
|
"stderrTruncated": stderr_bytes > MAX_STREAM_BYTES,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _subprocess_env(env: Mapping[str, str]) -> dict[str, str]:
|
||
|
|
return {
|
||
|
|
key: value for key, value in env.items()
|
||
|
|
if isinstance(value, str)
|
||
|
|
and (key in _SUBPROCESS_ENV_KEYS or re.fullmatch(r"LC_[A-Z0-9_]+", key))
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _destination_hash(destination: Path, project: Path, expected_name: str) -> str:
|
||
|
|
try:
|
||
|
|
if destination.is_symlink() or not destination.is_dir():
|
||
|
|
raise InstallerError("project destination is not a real directory")
|
||
|
|
resolved = destination.resolve(strict=True)
|
||
|
|
resolved.relative_to(project)
|
||
|
|
skill = registry.read_skill(resolved / "SKILL.md")
|
||
|
|
if skill["name"] != expected_name:
|
||
|
|
raise InstallerError("project destination frontmatter is invalid")
|
||
|
|
return registry.canonical_tree_sha256(resolved, safety_root=project)
|
||
|
|
except (OSError, ValueError, registry.RegistryError) as exc:
|
||
|
|
if isinstance(exc, InstallerError):
|
||
|
|
raise
|
||
|
|
raise InstallerError("project destination validation failed") from exc
|
||
|
|
|
||
|
|
|
||
|
|
def install_internal_preview(
|
||
|
|
name: object,
|
||
|
|
*,
|
||
|
|
repo_root: Path,
|
||
|
|
plugin_root: Path,
|
||
|
|
cwd: Path,
|
||
|
|
env: Mapping[str, str],
|
||
|
|
runner: Callable = subprocess.run,
|
||
|
|
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||
|
|
) -> tuple[int, dict]:
|
||
|
|
"""Install one eligible authoring variant into the current project.
|
||
|
|
|
||
|
|
The caller's explicit ``internal-preview install`` command is the user
|
||
|
|
authorization. This function independently requires the existing env gate;
|
||
|
|
it never adds or changes that gate itself.
|
||
|
|
"""
|
||
|
|
if env.get("SF_SKILLS_INTERNAL_PREVIEW") != "1":
|
||
|
|
return _error(name, "internal preview authorization denied")
|
||
|
|
if not isinstance(name, str) or len(name) > 64 or not registry.NAME_PATTERN.fullmatch(name):
|
||
|
|
return _error(name, "internal preview request denied")
|
||
|
|
if not isinstance(timeout_seconds, int) or not 1 <= timeout_seconds <= 300:
|
||
|
|
return _error(name, "internal preview request denied")
|
||
|
|
try:
|
||
|
|
repo, _plugin, authoring, foundation, public, held = _validate_checkout(repo_root, plugin_root)
|
||
|
|
project = _validate_project(cwd)
|
||
|
|
source = authoring.get(name)
|
||
|
|
if source is None or name not in held or name in foundation:
|
||
|
|
raise InstallerError("internal preview request denied")
|
||
|
|
expected_hash = registry.canonical_tree_sha256(source, safety_root=repo)
|
||
|
|
if name in public and public[name]["treeSha256"] == expected_hash:
|
||
|
|
raise InstallerError("internal preview request denied")
|
||
|
|
destination = project / ".claude" / "skills" / name
|
||
|
|
destination.parent.relative_to(project)
|
||
|
|
destination_exists = _inspect_destination_chain(project, destination)
|
||
|
|
except InstallerError as exc:
|
||
|
|
return _error(name, str(exc))
|
||
|
|
except (OSError, ValueError, registry.RegistryError):
|
||
|
|
return _error(name, "internal preview request denied")
|
||
|
|
|
||
|
|
if destination_exists:
|
||
|
|
try:
|
||
|
|
if _destination_hash(destination, project, name) != expected_hash:
|
||
|
|
raise InstallerError("project destination is not authoring-exact")
|
||
|
|
except InstallerError:
|
||
|
|
return _error(name, "internal preview request denied")
|
||
|
|
return 0, {
|
||
|
|
**_base(name),
|
||
|
|
"status": "already-installed",
|
||
|
|
"provenance": "authoring-exact",
|
||
|
|
"skillsLockPresent": (project / "skills-lock.json").is_file(),
|
||
|
|
}
|
||
|
|
|
||
|
|
argv = [
|
||
|
|
"npx", "--yes", CLI_VERSION, "add", str(repo / "skills"),
|
||
|
|
"--skill", name, "--agent", "claude-code", "--copy", "--yes",
|
||
|
|
]
|
||
|
|
try:
|
||
|
|
completed = runner(
|
||
|
|
argv,
|
||
|
|
cwd=project,
|
||
|
|
env=_subprocess_env(env),
|
||
|
|
capture_output=True,
|
||
|
|
shell=False,
|
||
|
|
timeout=timeout_seconds,
|
||
|
|
check=False,
|
||
|
|
)
|
||
|
|
except subprocess.TimeoutExpired as exc:
|
||
|
|
execution = _execution(None, exc.output, exc.stderr, timed_out=True)
|
||
|
|
return _error(name, "internal preview installer timed out", execution=execution)
|
||
|
|
except (OSError, ValueError):
|
||
|
|
return _error(name, "internal preview installer could not start")
|
||
|
|
except Exception:
|
||
|
|
return _error(name, "internal preview installer returned malformed output")
|
||
|
|
|
||
|
|
returncode = getattr(completed, "returncode", None)
|
||
|
|
execution = _execution(
|
||
|
|
returncode,
|
||
|
|
getattr(completed, "stdout", None),
|
||
|
|
getattr(completed, "stderr", None),
|
||
|
|
timed_out=False,
|
||
|
|
)
|
||
|
|
if type(returncode) is not int:
|
||
|
|
return _error(name, "internal preview installer returned malformed output", execution=execution)
|
||
|
|
if returncode != 0:
|
||
|
|
return _error(name, "internal preview installer failed", execution=execution)
|
||
|
|
try:
|
||
|
|
actual_hash = _destination_hash(destination, project, name)
|
||
|
|
except InstallerError:
|
||
|
|
return _error(name, "internal preview installation postcondition failed", execution=execution)
|
||
|
|
if actual_hash != expected_hash:
|
||
|
|
return _error(name, "internal preview installation content did not match authoring", execution=execution)
|
||
|
|
return 0, {
|
||
|
|
**_base(name),
|
||
|
|
"status": "installed",
|
||
|
|
"provenance": "authoring-exact",
|
||
|
|
"skillsLockPresent": (project / "skills-lock.json").is_file(),
|
||
|
|
"execution": execution,
|
||
|
|
}
|