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 the build when the zone evaluator stops being pure.

`openlatch evaluate` must produce a verdict on a box holding **nothing but the
binary** — no enrolment, no credential, no daemon, no configuration file. The
platform runs it exactly that way (D3), so every hidden read is a production
failure there and nowhere else: the developer's machine has a config file, a
keychain and a data directory, and the engine works beautifully on it right up
to the moment it ships.

A separate crate would have proved some of this at compile time. The engine is a
module instead (D8 — a published crate would buy no confidentiality on a public
repo and would buy the consumable shared library the architecture is removing),
so this scanner buys it, plus the clock and randomness calls a dependency graph
never sees.

The nine guarantees, and the rule that carries each:

  G1 no audit line                          client-subsystem
  G2 no outbox spool                        client-subsystem
  G3 no dedup map / session registry        client-subsystem, global-state
  G4 no event-driven filesystem access      filesystem
  G5 no network                             network, async-runtime
  G6 no clock                               wall-clock
  G7 no randomness, no identifier minting   randomness
  G8 no config / keyring / data directory   environment, client-subsystem
  G9 no global state                        global-state

Scope: `src/zone_eval/**/*.rs` and the `evaluate` command module
(`src/cli/commands/evaluate.rs`, or `src/cli/commands/evaluate/**/*.rs` if it
ever becomes a directory). Nothing else — the layers above the engine read
files for a living.

Dropped, in order of precedence:

  * `#[cfg(test)]` items — not compiled into the shipped binary and not
    reachable from one. The runtime claim is `tests/evaluate_side_effects.rs`'s
    job, not this scanner's.
  * anything carrying an explicit `engine-purity-ok:` waiver as a trailing
    comment. The waiver must state a reason: it is a documented exception, not
    a mute button. There is deliberately **no per-file waiver** — a file-level
    mute on this gate would exempt the one module the gate exists for.

Comments and string literals are blanked before matching, so the sentence
"never call SystemTime::now" in a doc comment does not fail the gate that
sentence exists to explain.

An absent `src/zone_eval/` is a reported PASS: the module is scheduled, and the
gate is meant to be green on the tree it is born into. A `src/zone_eval/` that
exists and yields zero files is a FAILURE — a gate that passes because it found
nothing is worse than no gate, and this repo has been bitten by exactly that
(the `cargo test <filter>` guard comment in pr-checks.yml).

Falsify it by hand — do this after any edit to the rules below:

    mkdir -p src/zone_eval
    printf 'pub fn t() -> u128 {\n    SystemTime::now().elapsed().unwrap().as_millis()\n}\n' \\
        > src/zone_eval/mod.rs
    python3 ci/check-engine-purity.py .   # exit 1 — one wall-clock violation
    printf '//! Never call SystemTime::now() here.\n' > src/zone_eval/mod.rs
    python3 ci/check-engine-purity.py .   # exit 0 — the rationale does not trip the gate
    rm src/zone_eval/mod.rs
    python3 ci/check-engine-purity.py .   # exit 1 — the directory exists and matched nothing
    rmdir src/zone_eval
    python3 ci/check-engine-purity.py .   # exit 0 — the module has not landed yet

Exit codes: 0 clean, 1 violations found, 2 bad invocation.
"""

from __future__ import annotations

import re
import sys
from dataclasses import dataclass
from pathlib import Path

# ── What counts as a violation ───────────────────────────────────────

# Every rule names the guarantee it carries, so a violation report says which
# line of the statelessness contract just moved, not merely which token matched.
@dataclass(frozen=True)
class Rule:
    name: str
    guarantee: str
    pattern: re.Pattern[str]
    hint: str


RULES: list[Rule] = [
    Rule(
        "wall-clock",
        "G6",
        # `Foo::now`, `Foo::now_utc`, `.elapsed()` — the shape, not a fixed list
        # of crates, because the next date crate is one dependency bump away.
        re.compile(r"\b[A-Za-z_]\w*::now(?:_[a-z_]+)?\b|\.elapsed\s*\("),
        "the timestamp arrives in the frame as `now_ms` — reading the wall clock "
        "makes every replay of the same input non-reproducible",
    ),
    Rule(
        "filesystem",
        "G4",
        re.compile(
            r"\bfs::(?:read|write|copy|remove|create|rename|metadata|canonicalize|File)\w*"
            r"|\bFile::(?:open|create)\b"
            r"|\bOpenOptions\b"
            r"|\bread_to_string\s*\("
            r"|\bread_dir\s*\("
            r"|\bcanonicalize\s*\("
            r"|\.(?:exists|metadata|symlink_metadata|is_file|is_dir)\s*\(\s*\)"
            r"|\binclude_str!|\binclude_bytes!"
        ),
        "the path classifier reads DECLARED STRINGS ONLY — the hook path walks up "
        "from the payload's working directory and this one must not. Reading stdin "
        "is the command layer's job: stream it with `BufRead::lines()`",
    ),
    Rule(
        "environment",
        "G8",
        re.compile(
            r"\benv::(?:var|var_os|vars|vars_os|current_dir|set_current_dir|home_dir|temp_dir)\b"
            r"|\bdirs::|\bdirectories::|\betcetera::|\bkeyring::|\bsecret_service::"
        ),
        "no config read, no keyring read, no data-directory resolution — a keychain "
        "prompt on a headless box is a hang, not an error",
    ),
    Rule(
        "network",
        "G5",
        re.compile(
            r"\breqwest::|\bureq::|\bhyper::|\bsocket2::|\brustls::"
            r"|\bstd::net\b|\bTcpStream\b|\bTcpListener\b|\bUdpSocket\b"
            r"|\bUnixStream\b|\bUnixListener\b|\bToSocketAddrs\b"
        ),
        "no poller, no ingest, no telemetry, no update check — evaluation reads its "
        "inputs and returns",
    ),
    Rule(
        "randomness",
        "G7",
        re.compile(
            r"\brand::|\brand_core::|\bthread_rng\b|\bOsRng\b|\bgetrandom\b"
            r"|\bUuid::|\buuid::|\bnew_v4\b|\bnanoid\b|\bulid::"
        ),
        "determinism is the requirement: the same row must yield the same bytes on "
        "every replay, so no randomness and no identifier minting",
    ),
    Rule(
        "async-runtime",
        "G5",
        re.compile(
            r"\btokio::|\bfutures::|\basync_std::"
            r"|\basync\s+(?:fn|move|\{)|\.await\b|\bblock_on\s*\(|\bspawn_blocking\b"
        ),
        "the entry point is a synchronous function of its arguments — an async "
        "signature here is the seam a network call arrives through later",
    ),
    Rule(
        "global-state",
        "G9",
        re.compile(
            r"\blazy_static!|\bthread_local!|\bstatic\s+mut\b"
            r"|\bOnceLock\b|\bOnceCell\b|\bLazyLock\b|\bLazy::|\bArcSwap\b"
            r"|\bAtomic(?:Bool|Usize|Isize|U8|U16|U32|U64|I8|I16|I32|I64|Ptr)\b"
            r"|\bstatic\s+[A-Z_][A-Z0-9_]*\s*:\s*(?:Mutex|RwLock)\b"
        ),
        "no resident bundle, no shared application state. The process may cache what "
        "is derived from its INPUTS; it may never retain what is derived from its "
        "HISTORY — and a global is how the second one gets built by accident",
    ),
    Rule(
        "client-subsystem",
        "G1/G2/G3/G8",
        re.compile(
            r"\bcrate::(?:daemon|app|hooks|hook_output|boundary)\b"
            r"|\bcrate::core::(?:cloud|config|auth|telemetry|update|crash_report"
            r"|hook_state|logging|egress|net|supervision|install_state)\b"
        ),
        "the audit line, the outbox spool, the dedup map and the session registry all "
        "hang off these modules — the outbox in particular is the DEFAULT when no "
        "cloud credential is present, which is exactly the platform's situation",
    ),
    Rule(
        "subprocess",
        "G5/G9",
        re.compile(r"\bprocess::(?:Command|exit|abort)\b|\bthread::spawn\b"),
        "a verdict is computed in this process, from these bytes — spawning anything "
        "moves the state and the failure mode somewhere the corpus cannot see",
    ),
]

WAIVER = re.compile(r"engine-purity-ok:\s*\S")
ATTR = re.compile(r"^\s*#!?\[")
CFG_TEST = re.compile(r"^\s*#!?\[\s*cfg\s*\(.*\btest\b")

# Everything that is not code, matched in one pass over the whole file so a
# construct spanning lines (a block comment, a multi-line raw string) cannot
# desynchronise the scan. Order matters: a `//` inside a string literal is part
# of the string, and a `"` inside a comment is not a string.
NON_CODE = re.compile(
    r'r(?P<hashes>#*)"(?:.|\n)*?"(?P=hashes)'  # raw strings, any hash count
    r'|"(?:\\.|[^"\\\n])*"'  # normal strings
    r"|'(?:\\.|[^'\\\n])'"  # char literals (a lifetime has no closing quote)
    r"|/\*(?:.|\n)*?\*/"  # block comments (Rust nests them; this does not)
    r"|//[^\n]*"  # line comments
)


@dataclass
class Violation:
    path: Path
    line: int
    rule: Rule
    text: str


def code_lines(text: str) -> list[str]:
    """The file with comments and literals blanked, line numbering preserved."""

    def blank(match: re.Match[str]) -> str:
        return re.sub(r"[^\n]", " ", match.group(0))

    return NON_CODE.sub(blank, text).splitlines()


def cfg_test_lines(lines: list[str]) -> set[int]:
    """0-based indices of `#[cfg(test)]` items, attributes included.

    Brace counting is safe here because `lines` is already blanked: a `{` in a
    format string or a comment cannot reach this function.
    """
    skip: set[int] = set()
    for i, raw in enumerate(lines):
        if not CFG_TEST.match(raw):
            continue
        if raw.lstrip().startswith("#!["):  # inner attribute gates the rest of the file
            skip.update(range(i, len(lines)))
            continue
        j = i + 1
        while j < len(lines) and (ATTR.match(lines[j]) or lines[j].lstrip().startswith("///")):
            j += 1
        depth, opened = 0, False
        for k in range(j, len(lines)):
            depth += lines[k].count("{") - lines[k].count("}")
            opened = opened or "{" in lines[k]
            if opened and depth <= 0:
                skip.update(range(i, k + 1))
                break
            if not opened and lines[k].rstrip().endswith(";"):
                skip.update(range(i, k + 1))
                break
        else:
            skip.update(range(i, len(lines)))
    return skip


def scan(path: Path) -> list[Violation]:
    text = path.read_text(encoding="utf-8")
    raw_lines = text.splitlines()
    lines = code_lines(text)
    skip = cfg_test_lines(lines)
    out: list[Violation] = []
    for i, line in enumerate(lines):
        if i in skip or WAIVER.search(raw_lines[i]):
            continue
        for rule in RULES:
            if rule.pattern.search(line):
                out.append(Violation(path, i + 1, rule, raw_lines[i].strip()))
                break
    return out


def targets(root: Path) -> tuple[list[Path], list[Path]]:
    """(files to scan, roots that exist).

    The second value is what separates "the module has not landed yet" from "the
    module is here and I matched nothing in it".
    """
    candidates = [
        root / "src" / "zone_eval",
        root / "src" / "cli" / "commands" / "evaluate",
        root / "src" / "cli" / "commands" / "evaluate.rs",
    ]
    present = [p for p in candidates if p.exists()]
    files: set[Path] = set()
    for path in present:
        if path.is_dir():
            files.update(path.rglob("*.rs"))
        elif path.suffix == ".rs":
            files.add(path)
    return sorted(files), present


def main(argv: list[str]) -> int:
    root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
    if not root.is_dir():
        print(f"error: {root} is not a directory", file=sys.stderr)
        return 2

    files, present = targets(root)

    if not present:
        print(
            "engine purity: src/zone_eval/ and src/cli/commands/evaluate.rs are both "
            "absent — the engine has not landed yet, so there is nothing to scan.\n"
            "This gate is green by construction until the module exists, and red the "
            "moment it exists and reaches for a clock, a file or the network."
        )
        return 0

    if not files:
        here = ", ".join(str(p.relative_to(root)) for p in present)
        print(f"::error::engine purity: {here} exists but holds no .rs file.\n")
        print(
            "A gate that passes because it found nothing is worse than no gate. Either "
            "the engine moved and this scanner's target list is stale, or the directory "
            "is an empty leftover — fix whichever it is."
        )
        return 1

    violations = [v for path in files for v in scan(path)]
    if not violations:
        print(f"engine purity: clean ({len(files)} file(s) scanned)")
        return 0

    print("::error::The evaluator must run on a box holding nothing but the binary:\n")
    for v in violations:
        rel = v.path.relative_to(root)
        print(f"  {rel}:{v.line}  [{v.rule.name}]  {v.rule.guarantee}")
        print(f"      {v.text}")
        print(f"{v.rule.hint}\n")
    print(f"{len(violations)} violation(s).")
    print(
        "Move the read into the command layer above the engine and hand the bytes "
        "down, or append `// engine-purity-ok: <reason>` when the call really is "
        "input-derived and side-effect free."
    )
    return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv))