molecule-ci/scripts/validate-workspace-template.py
Hongming Wang d420b4a24f ci: lock down validate-workspace-template against fork-PR untrusted code (P135)
Splits the reusable validator into two jobs to keep external fork
PRs from running arbitrary template code on the runner.

Background

The reusable workflow runs three primitives that execute
template-supplied code:
  - pip install -r requirements.txt  (setup.py + post-install hooks)
  - importlib.exec_module(adapter)   (top-level Python in adapter.py)
  - docker build                     (RUN steps in Dockerfile)

Token scope is already minimal (contents: read), GitHub forced
fork-PR tokens read-only in 2021, and the workflow_call interface
doesn't accept secrets. So the actual exploit surface is "what can
a malicious actor do with arbitrary code execution on a GitHub-
hosted runner that has no useful credentials?" — answer: crypto-
mine, DNS-exfiltrate runner metadata, attempt lateral movement
within the runner's network. Annoying, not catastrophic, but a
real attack surface that this PR closes.

The fix

Two-job split:

  validate-static    Always runs, including external fork PRs.
                     File-content checks (secret scan, YAML parse,
                     AST inspection of adapter.py without import),
                     pip install only the validator's pyyaml dep
                     (not the template's requirements.txt). NO
                     third-party code execution.

  validate-runtime   Skipped when github.event.pull_request.head.
                     repo.fork == true. pip install requirements.txt
                     + adapter import + docker build. Internal PRs
                     and push events to internal branches still get
                     the full coverage.

The validator script gains a --static-only flag that skips
check_adapter_runtime_load() (the function that calls
exec_module). The validate-static job uses it; validate-runtime
uses the existing full mode.

Trade-off

External contributors get static feedback only on their PR. If
their template metadata passes static checks but breaks runtime
loading, branch protection on staging/main blocks the merge once
runtime validation runs (post-merge or after an internal
contributor reposts). Fewer false-positive CI failures for honest
external contributors; same coverage at the merge-protected
boundary.

What this does NOT close

- Maintainer-approved external PRs that consciously execute
  third-party code. The maintainer must approve a workflow run
  via GitHub's first-time-contributor gate; that's a human
  decision, not a workflow-level gate.
- requirements.txt that pulls a malicious transitive dep from
  PyPI even on internal PRs. Mitigated by branch-protection +
  human review of PRs that touch requirements.txt.

Closes task #135.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:07:58 -07:00

441 lines
18 KiB
Python

#!/usr/bin/env python3
"""Prototype of the beefed-up validate-workspace-template.py.
Run from a template repo's root. Surfaces hard structural drift in
Dockerfile + config.yaml + requirements.txt against the canonical
contract. Replaces the existing soft-warnings-only validator at
molecule-ci/scripts/validate-workspace-template.py.
"""
import os, re, sys
import yaml
ERRORS: list[str] = []
WARNINGS: list[str] = []
def err(msg: str) -> None:
ERRORS.append(msg)
def warn(msg: str) -> None:
WARNINGS.append(msg)
# ───────────────────────────────────────────────────────────── Dockerfile
def check_dockerfile() -> None:
if not os.path.isfile("Dockerfile"):
warn("no Dockerfile — skipping container drift checks (library-only template?)")
return
df = open("Dockerfile").read()
if not re.search(r"^FROM python:3\.11-slim\b", df, re.MULTILINE):
err("Dockerfile: must base on `FROM python:3.11-slim` — see contract doc")
if not re.search(r"^ARG RUNTIME_VERSION", df, re.MULTILINE):
err(
"Dockerfile: missing `ARG RUNTIME_VERSION=`. "
"This arg invalidates the pip-install cache when the cascade "
"publishes a new wheel; without it, the cascade silently ships "
"the previous runtime (cache trap observed 2026-04-27, 5x in a row)."
)
if "molecule-ai-workspace-runtime" not in df and not (
os.path.isfile("requirements.txt")
and "molecule-ai-workspace-runtime" in open("requirements.txt").read()
):
err("Dockerfile + requirements.txt: must install `molecule-ai-workspace-runtime`")
if "${RUNTIME_VERSION}" not in df and "$RUNTIME_VERSION" not in df:
err(
"Dockerfile: must reference `${RUNTIME_VERSION}` in a pip install RUN block. "
'Pattern: `if [ -n "${RUNTIME_VERSION}" ]; then '
'pip install --no-cache-dir --upgrade "molecule-ai-workspace-runtime==${RUNTIME_VERSION}"; fi`'
)
if not re.search(r"useradd[^\n]*\bagent\b", df):
err(
"Dockerfile: must create the `agent` user "
"(`RUN useradd -u 1000 -m -s /bin/bash agent`). "
"Runtime drops to uid 1000; without it, claude-code refuses "
"`--dangerously-skip-permissions` for safety."
)
has_direct_entrypoint = bool(
re.search(r'(ENTRYPOINT|CMD)\s*\[?\s*"?molecule-runtime"?', df)
)
has_custom_entrypoint = bool(
re.search(r'ENTRYPOINT\s*\[?\s*"?(/?[\w./-]*entrypoint\.sh|/?[\w./-]*start\.sh)', df)
)
if not has_direct_entrypoint and not has_custom_entrypoint:
err(
"Dockerfile: must end at `molecule-runtime` "
"(`ENTRYPOINT [\"molecule-runtime\"]` or via custom "
"entrypoint.sh / start.sh that exec's molecule-runtime)"
)
if has_custom_entrypoint:
m = re.search(r'ENTRYPOINT\s*\[?\s*"?(/?[\w./-]+)', df)
if m:
ep_in_image = m.group(1).lstrip("/")
ep_local = os.path.basename(ep_in_image)
if os.path.isfile(ep_local):
if "molecule-runtime" not in open(ep_local).read():
err(
f"Dockerfile uses ENTRYPOINT [{ep_in_image}] but "
f"{ep_local} does not exec `molecule-runtime`"
)
else:
warn(
f"Dockerfile points ENTRYPOINT at {ep_in_image} but "
f"{ep_local} not found in repo root — verify it's COPYed in"
)
# ───────────────────────────────────────────────────────────── config.yaml
KNOWN_RUNTIMES = {
"langgraph",
"claude-code",
"crewai",
"autogen",
"deepagents",
"hermes",
"gemini-cli",
"openclaw",
}
# ──────────────────────────────────────────── schema versioning
#
# `template_schema_version: int` in each template's config.yaml selects
# which contract this validator enforces. Versions are FROZEN once
# shipped — never edit a SCHEMA_V* constant in place. To bump:
#
# 1. Add `SCHEMA_V<N+1>_REQUIRED_KEYS` / `SCHEMA_V<N+1>_OPTIONAL_KEYS`
# describing the new contract.
# 2. Add `_check_schema_v<N+1>(config)` that enforces it.
# 3. Add the entry to SCHEMA_CHECKS below.
# 4. Move version N from KNOWN_SCHEMA_VERSIONS to
# DEPRECATED_SCHEMA_VERSIONS so existing v<N> templates warn but
# still pass — buys a deprecation window.
# 5. Ship a corresponding migration in scripts/migrate-template.py's
# MIGRATIONS table (key = N, value = callable that produces the
# v<N+1> dict from a v<N> dict).
# 6. Run migrate-template.py on each consumer template repo as a PR.
# 7. After all consumers migrate, drop version N from
# DEPRECATED_SCHEMA_VERSIONS in a follow-up PR.
#
# This discipline means a schema version always has exactly one valid
# enforcement function, never "branch on minor variants" — the whole
# point of versioning is to avoid that drift.
KNOWN_SCHEMA_VERSIONS: set[int] = {1}
DEPRECATED_SCHEMA_VERSIONS: set[int] = set()
# `template_schema_version` is part of the v1 contract and listed
# here for documentation, but the top-level `check_config_yaml`
# already verifies it's present and is an int before dispatching
# here — `_check_schema_v1` does NOT re-check it (would be dead
# defensive code). The key DOES need to appear in the union of
# required + optional so it isn't flagged as unknown drift in the
# `unknown top-level keys` warning at the end of `_check_schema_v1`.
SCHEMA_V1_REQUIRED_KEYS = ["name", "runtime", "template_schema_version"]
SCHEMA_V1_OPTIONAL_KEYS = [
"description",
"version",
"tier",
"model",
"models",
"runtime_config",
"env",
"skills",
"tools",
"a2a",
"delegation",
"prompt_files",
"bridge",
"governance",
]
def _check_schema_v1(config: dict) -> None:
"""v1 contract — the keys frozen as of monorepo task #90's Phase 2.
Currently every production template runs this version. Do NOT edit
in place; add v2 instead and migrate consumers (see header)."""
for key in SCHEMA_V1_REQUIRED_KEYS:
if key == "template_schema_version":
# Already verified present + int by the dispatcher; skip
# to avoid emitting a duplicate or contradictory error.
continue
if key not in config:
err(f"config.yaml: missing required key `{key}`")
runtime = config.get("runtime")
if runtime and runtime not in KNOWN_RUNTIMES:
warn(
f"config.yaml: runtime `{runtime}` not in known set "
f"{sorted(KNOWN_RUNTIMES)} — OK for custom runtimes; "
f"if canonical, add it to KNOWN_RUNTIMES in validate-workspace-template.py"
)
unknown = set(config.keys()) - set(SCHEMA_V1_REQUIRED_KEYS) - set(SCHEMA_V1_OPTIONAL_KEYS)
if unknown:
warn(
f"config.yaml: unknown top-level keys {sorted(unknown)}"
f"may be drift. If intentional, add them to SCHEMA_V1_OPTIONAL_KEYS."
)
SCHEMA_CHECKS = {
1: _check_schema_v1,
}
def check_config_yaml() -> None:
if not os.path.isfile("config.yaml"):
err("config.yaml: missing at repo root")
return
with open("config.yaml") as f:
try:
config = yaml.safe_load(f)
except yaml.YAMLError as e:
err(f"config.yaml: invalid YAML — {e}")
return
if not isinstance(config, dict):
err(f"config.yaml: root must be a mapping, got {type(config).__name__}")
return
# Schema-version dispatch. Validate the version field shape first
# so error messages are actionable.
sv = config.get("template_schema_version")
if sv is None:
err("config.yaml: missing required key `template_schema_version`")
# Can't dispatch without a version. Don't fall through to v1
# checks — that would mask the missing-version error.
return
if not isinstance(sv, int):
err(
f"config.yaml: template_schema_version must be int, "
f"got {type(sv).__name__}={sv!r}"
)
return
if sv in DEPRECATED_SCHEMA_VERSIONS:
latest = max(KNOWN_SCHEMA_VERSIONS)
warn(
f"config.yaml: template_schema_version={sv} is deprecated; "
f"migrate to v{latest} via "
f"`python3 scripts/migrate-template.py --to {latest} .`. "
f"Support for v{sv} will be removed in a future cycle."
)
elif sv not in KNOWN_SCHEMA_VERSIONS:
valid = sorted(KNOWN_SCHEMA_VERSIONS | DEPRECATED_SCHEMA_VERSIONS)
err(
f"config.yaml: template_schema_version={sv} is unknown — "
f"this validator understands {valid}. Either bump the "
f"validator (add a SCHEMA_V{sv} block) or correct the version."
)
return
SCHEMA_CHECKS[sv](config)
# ───────────────────────────────────────────────────────────── requirements.txt
def check_requirements() -> None:
if not os.path.isfile("requirements.txt"):
warn("no requirements.txt — Dockerfile must install runtime by other means")
return
reqs = open("requirements.txt").read()
if "molecule-ai-workspace-runtime" not in reqs:
err("requirements.txt: must declare `molecule-ai-workspace-runtime` as a dependency")
# ───────────────────────────────────────────────────────────── adapter.py
def check_adapter() -> None:
"""Static-text adapter checks. Fast — no imports."""
if not os.path.isfile("adapter.py"):
warn("no adapter.py — runtime will use the default langgraph executor from the wheel")
return
content = open("adapter.py").read()
# The original validator's warning ("don't import molecule_runtime") was
# backwards — that's the canonical package name. The previous check shipped
# for ~2 weeks producing false-positive warnings. Removed.
if re.search(r"\bfrom molecule_ai\b|\bimport molecule_ai\b", content):
warn(
"adapter.py imports `molecule_ai` — that's a pre-#87 package name; "
"use `molecule_runtime`"
)
def check_adapter_runtime_load() -> None:
"""Strong adapter contract: import adapter.py the same way the runtime
does at workspace boot, and assert at least one class in it inherits
from molecule_runtime.adapters.base.BaseAdapter.
The Docker build smoke test in validate-workspace-template.yml builds
the image but doesn't RUN it — adapter.py is only imported at
container startup. So a template with a syntactically-valid Dockerfile
+ a broken adapter.py (wrong base class, ImportError on a missing
framework dep, typo) builds clean and fails on first user prompt.
This check exercises the same class-resolution path the runtime uses,
so a passing validator means a passing workspace boot for the
adapter-load step.
Skip conditions:
- No adapter.py exists. Templates without one inherit the default
langgraph executor from the wheel (intentional, not drift).
- molecule-ai-workspace-runtime not importable in the validator
environment. That's a CI-config bug — the workflow that runs
this validator must `pip install molecule-ai-workspace-runtime`
first. Warn loudly so the misconfiguration surfaces, but don't
hard-fail (we'd be saying "your adapter is broken" when the
actual cause is missing infra). The `pip install -r
requirements.txt` step in validate-workspace-template.yml
normally satisfies this transitively.
Hard-error conditions:
- adapter.py raises any exception during import. The same
exception would crash workspace boot.
- No class in the module inherits from BaseAdapter. The runtime's
adapter-discovery would silently fall through to the default
executor, ignoring this file — exactly the kind of human-error
mode this contract is supposed to eliminate.
"""
if not os.path.isfile("adapter.py"):
return # check_adapter() already warned; don't double-warn
try:
from molecule_runtime.adapters.base import BaseAdapter # noqa: PLC0415
except ImportError:
warn(
"adapter.py: skipping runtime-load check — "
"`molecule-ai-workspace-runtime` not installed in the validator "
"environment. The CI workflow that invokes this script must "
"`pip install molecule-ai-workspace-runtime` (or `pip install "
"-r requirements.txt`) first; otherwise this critical check is "
"silently bypassed."
)
return
# Load adapter.py as a module under a per-call-unique name so it
# doesn't collide with any installed `adapter` package OR with a
# previous invocation in the same Python process. The id() of the
# cwd-anchored absolute path is sufficient — we just need
# different invocations to land on different sys.modules keys so
# one invocation's lingering references can't bleed into the
# next's adapter discovery.
import importlib.util # noqa: PLC0415
import sys # noqa: PLC0415
abs_path = os.path.abspath("adapter.py")
module_name = f"_template_adapter_under_validation_{abs(hash(abs_path)):x}"
spec = importlib.util.spec_from_file_location(module_name, "adapter.py")
if spec is None or spec.loader is None:
err("adapter.py: cannot construct an import spec — file may be unreadable")
return
mod = importlib.util.module_from_spec(spec)
sys.modules[module_name] = mod # required so dataclass / pydantic refs resolve
try:
spec.loader.exec_module(mod)
except Exception as e:
err(
f"adapter.py: failed to import — `{type(e).__name__}: {e}`. "
f"This is the same failure mode that crashes workspace boot at "
f"runtime; the cure is to fix the adapter, not skip this check. "
f"If the import fails because a transitive dep isn't installed in "
f"this CI env, add it to the template's requirements.txt — that's "
f"what the workspace container does, and the validator job "
f"installs requirements.txt before running this check."
)
sys.modules.pop(module_name, None)
return
# Class discovery: only count CONCRETE classes DEFINED in
# adapter.py, not re-exported imports and not abstract
# intermediates. Three filter axes:
#
# 1. `__module__ == module_name` — defined HERE, not imported
# from molecule_runtime or a third-party framework.
# 2. `obj is not BaseAdapter` — BaseAdapter itself doesn't count.
# 3. `not inspect.isabstract(obj)` — abstract intermediates
# defined locally don't count. Catches the
# `class Framework(BaseAdapter): pass` + `class Concrete(Framework):`
# pattern where vars(mod) has BOTH and we'd otherwise count
# both as "real" adapters.
import inspect # noqa: PLC0415
# Deduplicate by class identity. Many production adapters do
# `Adapter = ConcreteAdapter` as a module-level alias for the
# runtime's discovery — `vars(mod)` returns both bindings
# (`Adapter` AND `ConcreteAdapter`) pointing at the same class
# object. Without dedup, the multiple-concrete-subclasses
# error fires falsely on every aliased template.
adapter_classes = list({
id(obj): obj
for name, obj in vars(mod).items()
if isinstance(obj, type)
and obj is not BaseAdapter
and issubclass(obj, BaseAdapter)
and getattr(obj, "__module__", None) == module_name
and not inspect.isabstract(obj)
}.values())
sys.modules.pop(module_name, None)
if not adapter_classes:
err(
"adapter.py: no concrete class inheriting from "
"`molecule_runtime.adapters.base.BaseAdapter` defined "
"in this file. The runtime resolves the adapter via "
"class discovery on adapter.py's own definitions — "
"imports of base classes from molecule_runtime do not "
"count, and abstract intermediates do not count. "
"Without a concrete subclass DEFINED here, workspace "
"boot falls through to the default langgraph executor "
"and ignores this file silently. If that's intentional, "
"delete adapter.py."
)
return
if len(adapter_classes) > 1:
names = sorted(c.__name__ for c in adapter_classes)
err(
f"adapter.py: multiple concrete BaseAdapter subclasses "
f"defined: {names}. The runtime's class-discovery picks "
f"one per its own resolution rules (typically last-defined "
f"or first-by-iteration), so shipping more than one is a "
f"silent ambiguity — the wrong class might be loaded after "
f"a future runtime refactor. Either keep exactly one "
f"concrete subclass + mark the others abstract via "
f"`abc.ABC` / abstract methods, or move them to separate "
f"importable modules."
)
def main() -> None:
# --static-only skips check_adapter_runtime_load(), which calls
# importlib's exec_module() on the template's adapter.py. That's
# untrusted code execution — fine on internal PRs and post-merge,
# unsafe on external fork PRs (#135). Static checks (file presence,
# YAML parse, regex/AST inspection) stay enabled in static mode.
static_only = "--static-only" in sys.argv
check_dockerfile()
check_config_yaml()
check_requirements()
check_adapter()
if not static_only:
check_adapter_runtime_load()
else:
print("::notice::skipping adapter.py import check (--static-only mode)")
for w in WARNINGS:
print(f"::warning::{w}")
for e in ERRORS:
print(f"::error::{e}")
if ERRORS:
sys.exit(1)
suffix = " [static-only]" if static_only else ""
print(f"✓ Template validation passed ({len(WARNINGS)} warning(s)){suffix}")
if __name__ == "__main__":
main()