openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
#!/usr/bin/env python3
"""Fail on shell that a bash 3.2 cannot run, in a step macOS actually runs.

The macOS runners' `/bin/bash` is 3.2.57 and `shell: bash` resolves to it. Every
other runner here has bash 5, and `publish.yml` holds the only macOS jobs — which
run only on a tag a release PR has already merged. So a bash 4 construct is
invisible until the release is cut: v0.3.2 lost both macOS release jobs to a
`case` inside `$(...)`, which 3.2 mis-parses by counting parentheses, and the tag
was spent with npm still on the previous version.

`bash -n` under a real 3.2 is the complete half — it rejects everything 3.2's
PARSER rejects. RUNTIME_ONLY below is the deliberately incomplete half: bash 4
features 3.2 parses and only fails on when run, which `bash -n` never sees. Waive
a false positive with a `bash32-ok` comment on the line.

Run: `uv run --with pyyaml python ci/check-macos-bash.py .`
The 3.2 is `docker run bash:3.2`, or `$MACOS_BASH` if that names one. Both are
version-probed: without a real 3.2 this fails rather than passing quietly.
"""

from __future__ import annotations

import os
import pathlib
import re
import shutil
import subprocess
import sys

import yaml

# Docker Hub first, then the AWS mirror of the same official image: Hub rate-limits
# anonymous pulls per IP, CI runners share those, and a gate that goes red because
# someone else pulled is a gate people learn to re-run rather than read.
BASH32_IMAGES = ("bash:3.2", "public.ecr.aws/docker/library/bash:3.2")

VERSION_PROBE = 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'

# Bash 4 features that 3.2 PARSES and then fails on, so `bash -n` stays silent.
# Not a complete list and cannot be one — it is a second net under the parser,
# not the gate itself. Add what bites; waive a false hit with `bash32-ok`.
RUNTIME_ONLY: tuple[tuple[str, str], ...] = (
    (r"\b(declare|local|typeset|readonly)\s+-[A-Za-z]*[Agn]\b", "declare -A/-g/-n (bash 4)"),
    (r"\b(mapfile|readarray|coproc)\b", "mapfile/readarray/coproc (bash 4)"),
    (r"\$\{[A-Za-z0-9_#@*][A-Za-z0-9_\[\]@*]*(,,|\^\^|,|\^)", "case modification ${v,,} (bash 4)"),
    (r"\$\{[^{}]*@[QEPAaKkUuL]\}", "parameter transformation ${v@Q} (bash 4.4)"),
    (r"\bshopt\s+-s\s+(globstar|lastpipe|dirspell|autocd)\b", "bash 4 shopt option"),
    (r"\bwait\s+-n\b", "wait -n (bash 4.3)"),
    (r"\bread\s+-[A-Za-z]*N\b", "read -N (bash 4.1)"),
    (r"\bexec\s+\{[A-Za-z_]", "exec {fd}> named descriptor (bash 4.1)"),
)

WAIVER = "bash32-ok"
# `${{ ... }}` is substituted before the runner ever sees the script, so replace
# it with an inert token rather than trying to parse it. Non-greedy to `}}`, because
# `format('{0}', x)` puts a `}` inside the expression.
EXPRESSION = re.compile(r"\$\{\{.*?\}\}", re.DOTALL)
# A whole condition that is exactly one `runner.os` comparison and nothing else.
# Anything compound (`||`, a matrix predicate) is treated as reaching macOS: the
# cost of checking a step that never runs there is nil, and the cost of skipping
# one that does is a spent tag.
OS_ONLY = re.compile(r"^\s*(?:\$\{\{)?\s*runner\.os\s*(==|!=)\s*'(\w+)'\s*(?:\}\})?\s*$")


def runs_on_macos(job: dict) -> bool:
    runs_on = job.get("runs-on", "")
    if isinstance(runs_on, list):
        runs_on = " ".join(str(entry) for entry in runs_on)
    runs_on = str(runs_on)
    # `macOS` is also the standard self-hosted label, so compare case-insensitively.
    if "macos" in runs_on.lower():
        return True
    # `runs-on: ${{ matrix.os }}` — ask the matrix instead.
    return "${{" in runs_on and "macos" in yaml.safe_dump(job.get("strategy", {})).lower()


def reaches_macos(step: dict) -> bool:
    gate = OS_ONLY.match(str(step.get("if", "")))
    if not gate:
        return True
    operator, name = gate.groups()
    return (name == "macOS") if operator == "==" else (name != "macOS")


def is_bash(shell: str | None) -> bool:
    # Unspecified means bash on a macOS runner. A shell may be spelled as a path
    # (`/usr/bin/env bash {0}`), so compare the last word before the `{0}`.
    words = [word for word in str(shell or "bash").split() if not word.startswith(("-", "{"))]
    return bool(words) and os.path.basename(words[-1] if words[-1] != "env" else words[0]) == "bash"


def collect(where: str, steps: list[dict], default_shell: str | None,
            root: pathlib.Path, seen: set[pathlib.Path]) -> list[tuple[str, str]]:
    """Every bash block a macOS runner would execute, following local actions."""
    blocks: list[tuple[str, str]] = []
    for index, step in enumerate(steps):
        if not reaches_macos(step):
            continue
        label = f"{where}: {step.get('name', f'step {index}')}"
        inputs = step.get("with") or {}
        if "run" in step and is_bash(step.get("shell", default_shell)):
            blocks.append((label, str(step["run"])))
        # An action that takes shell source as an input runs it the same way —
        # `nick-fields/retry` is how the E2E suite runs on both macOS targets.
        if "command" in inputs and is_bash(inputs.get("shell")):
            blocks.append((f"{label} (command input)", str(inputs["command"])))
        uses = str(step.get("uses", ""))
        if uses.startswith("./"):
            action = root / uses[2:]
            for name in ("action.yml", "action.yaml"):
                if (action / name).exists():
                    action = action / name
                    break
            if action.is_file() and action not in seen:
                seen.add(action)
                spec = yaml.safe_load(action.read_text(encoding="utf-8")) or {}
                blocks += collect(str(action.relative_to(root)),
                                  (spec.get("runs") or {}).get("steps") or [], None, root, seen)
    return blocks


def macos_bash_blocks(root: pathlib.Path) -> list[tuple[str, str]]:
    blocks: list[tuple[str, str]] = []
    for workflow in sorted((root / ".github" / "workflows").glob("*.y*ml")):
        spec = yaml.safe_load(workflow.read_text(encoding="utf-8")) or {}
        default = ((spec.get("defaults") or {}).get("run") or {}).get("shell")
        for name, job in (spec.get("jobs") or {}).items():
            if not runs_on_macos(job):
                continue
            shell = ((job.get("defaults") or {}).get("run") or {}).get("shell") or default
            blocks += collect(f"{workflow.name} :: {name}", job.get("steps") or [],
                              shell, root, set())
    return blocks


def bash32_command() -> list[str] | None:
    """A bash that reports 3.2 — a mutable tag or a wrong $MACOS_BASH is not one."""
    def probes_32(command: list[str]) -> bool:
        result = subprocess.run(command + ["-c", VERSION_PROBE], capture_output=True, text=True)
        return result.stdout.strip() == "3.2"

    override = os.environ.get("MACOS_BASH")
    if override:
        return [override] if probes_32([override]) else None
    if not shutil.which("docker"):
        return None
    for image in BASH32_IMAGES:
        for probe in (["image", "inspect"], ["pull", "--quiet"]):
            if subprocess.run(["docker", *probe, image], capture_output=True).returncode == 0:
                command = ["docker", "run", "--rm", "-i", image, "bash"]
                if probes_32(command):
                    return command
                break
    return None


def parse_check(bash: list[str], blocks: list[tuple[str, str]]) -> list[str]:
    failures = []
    for label, source in blocks:
        result = subprocess.run(bash + ["-n", "-s"], input=source, capture_output=True, text=True)
        if result.returncode != 0:
            detail = (result.stderr or result.stdout).strip().replace("\n", "\n      ")
            failures.append(f"{label}\n      {detail}")
    return failures


def runtime_check(blocks: list[tuple[str, str]]) -> list[str]:
    failures = []
    for label, source in blocks:
        for number, line in enumerate(source.splitlines(), start=1):
            if line.lstrip().startswith("#") or WAIVER in line:
                continue
            for pattern, why in RUNTIME_ONLY:
                if re.search(pattern, line):
                    failures.append(f"{label} (line {number}): {why}\n      {line.strip()}")
                    break
    return failures


def main() -> int:
    root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
    blocks = [(label, EXPRESSION.sub("GHA_EXPRESSION", source))
              for label, source in macos_bash_blocks(root)]
    if not blocks:
        print("::error::No macOS bash steps found — this check has stopped checking anything.")
        return 1

    bash = bash32_command()
    if bash is None:
        print("::error::No bash 3.2 available. Install docker (for "
              f"`{'` or `'.join(BASH32_IMAGES)}`), or point $MACOS_BASH at a bash 3.2 binary.")
        return 1

    failures = parse_check(bash, blocks) + runtime_check(blocks)
    if failures:
        print(f"Shell that bash 3.2 cannot run, in {len(failures)} macOS step(s):\n")
        for failure in failures:
            print(f"  {failure}\n")
        print("Rewrite the construct — a `case` inside `$(...)` needs its pattern "
              f"written as `(pattern)`, or no `case` at all. A false positive takes a "
              f"`{WAIVER}` comment on the line.")
        return 1

    print(f"macOS bash 3.2 check: clean ({len(blocks)} step(s) under {' '.join(bash)})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())