Files
openworker/coworker/toolchain.py
T
Rohit C Prasad b866157778 Managed tools land on the persistent shell's PATH
install() links binaries into a stable tools/bin dir; LocalExecutor appends it
at spawn, so a mid-session install works by name without a respawn.
2026-08-14 23:50:51 -07:00

291 lines
11 KiB
Python

"""Finding (and optionally installing) the CLI tools a coworker's skills drive.
Two problems, deliberately kept apart (OPE-82):
* **The user's own toolchain** — aws, kubectl, terraform, gh, node. The whole point is
*their* installed, configured, credentialed copy, so we only ever LOCATE these. The
desktop shell hands us the login shell's PATH at spawn (OPE-83); `resolve()` is the
belt-and-braces for every other launch path (headless, systemd, a double-clicked
binary) — it also searches the dirs launchd's PATH never covers.
* **Tools a skill fundamentally IS** — the scanners behind the security bundles. Those
we can install and PIN, so a security review is reproducible instead of depending on
whatever version the user's package manager happened to ship.
Everything returns an ABSOLUTE path: once resolved, invocation never depends on PATH
again, so a tool found here works even if the caller's environment is bare.
Nothing here downloads anything on its own. `install()` runs only when the user has
approved it (via `request_tool`, OPE-85) — fetching an executable is a supply-chain
decision, so it is pinned by version, verified by SHA-256, and never implicit.
"""
from __future__ import annotations
import hashlib
import os
import platform
import shutil
import stat
import sys
import tarfile
import tempfile
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
from urllib.parse import urlparse
from .secrets import state_dir
# Dirs that hold user-installed CLIs but never appear in launchd's PATH. Mirrors
# KNOWN_TOOL_DIRS in the desktop shell (src-tauri/src/lib.rs) — keep the two in step.
_KNOWN_DIRS: tuple[str, ...] = (
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/opt/local/bin",
"~/.local/bin",
"~/.cargo/bin",
"~/go/bin",
)
def managed_dir() -> Path:
"""Where we keep tools we installed ourselves (never the user's own copies)."""
return state_dir() / "tools"
def bin_dir() -> Path:
"""One stable dir of links to the current pinned binaries. Binaries themselves live
in versioned dirs; this is what goes on a shell's PATH, so a tool installed mid-
session is picked up by the already-running shell without a respawn."""
return managed_dir() / "bin"
def _platform_key() -> str:
"""`<os>_<arch>` using the naming the upstream release assets use."""
system = {"darwin": "darwin", "linux": "linux", "win32": "windows"}.get(
sys.platform, sys.platform
)
machine = platform.machine().lower()
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
return f"{system}_{arch}"
@dataclass(frozen=True)
class Download:
url: str
sha256: str
# Path of the binary inside the archive; None when the asset IS the binary.
member: Optional[str] = None
@dataclass(frozen=True)
class ManagedTool:
name: str
version: str
# platform key -> download
downloads: dict[str, Download]
summary: str
# Pinned scanner registry. Versions and digests are copied from the upstream release's
# own checksum manifest; bumping a tool means bumping the digest in the same commit.
#
# Not every scanner belongs here: semgrep is distributed as a Python package (pip/brew),
# so we resolve the user's install rather than half-managing a copy. tfsec is absent on
# purpose — it's deprecated upstream and `trivy config` is its successor.
MANAGED: dict[str, ManagedTool] = {
"gitleaks": ManagedTool(
name="gitleaks",
version="8.30.1",
summary="scans git history and the working tree for committed secrets",
downloads={
"darwin_arm64": Download(
url="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_darwin_arm64.tar.gz",
sha256="b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5",
member="gitleaks",
),
"darwin_amd64": Download(
url="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_darwin_x64.tar.gz",
sha256="dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709",
member="gitleaks",
),
"linux_amd64": Download(
url="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz",
sha256="551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb",
member="gitleaks",
),
},
),
"trivy": ManagedTool(
name="trivy",
version="0.74.0",
summary="scans IaC/config, container images, and filesystems for misconfigurations and vulnerabilities",
downloads={
"darwin_arm64": Download(
url="https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_macOS-ARM64.tar.gz",
sha256="1caada5e0e2091909357c7525d3aa76f4b660b13821bc143b190c7483e31cc11",
member="trivy",
),
"darwin_amd64": Download(
url="https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_macOS-64bit.tar.gz",
sha256="472816f6888dda689d075c30254d4210b4d1035acf365aa72332f584c2f60485",
member="trivy",
),
"linux_amd64": Download(
url="https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.tar.gz",
sha256="2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a",
member="trivy",
),
},
),
"osv-scanner": ManagedTool(
name="osv-scanner",
version="2.5.0",
summary="checks dependency lockfiles against the OSV vulnerability database",
downloads={
"darwin_arm64": Download(
url="https://github.com/google/osv-scanner/releases/download/v2.5.0/osv-scanner_darwin_arm64",
sha256="fff5a2e351b7f0a60001e87cbf862e82fb82e2792d368b533fec7a5865a73da2",
),
"darwin_amd64": Download(
url="https://github.com/google/osv-scanner/releases/download/v2.5.0/osv-scanner_darwin_amd64",
sha256="baef4f4a4ce2924a9241869c36d4bd9d6c04b632cae6637a0f6347ab9272eb16",
),
"linux_amd64": Download(
url="https://github.com/google/osv-scanner/releases/download/v2.5.0/osv-scanner_linux_amd64",
sha256="edcfc41d257db36148f065055655fe3fcfc434b0b423ea67468a84c207524e0c",
),
},
),
}
def _managed_path(tool: ManagedTool) -> Path:
exe = tool.name + (".exe" if sys.platform == "win32" else "")
return managed_dir() / tool.name / tool.version / exe
def resolve(name: str) -> Optional[str]:
"""Absolute path to `name`, or None. PATH first (the user's choice wins), then the
dirs a GUI launch can't see, then anything we installed ourselves."""
found = shutil.which(name)
if found:
return str(Path(found).resolve())
for raw in _KNOWN_DIRS:
candidate = Path(raw).expanduser() / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate.resolve())
tool = MANAGED.get(name)
if tool:
managed = _managed_path(tool)
if managed.is_file() and os.access(managed, os.X_OK):
return str(managed)
return None
def have(name: str) -> bool:
return resolve(name) is not None
def missing(names: Iterable[str]) -> list[str]:
"""Which of `names` we can't find — what a skill checks before promising a scan."""
return [n for n in names if not have(n)]
def installable(name: str) -> bool:
"""Whether we could install this ourselves (i.e. it's pinned for this platform)."""
tool = MANAGED.get(name)
return bool(tool and _platform_key() in tool.downloads)
def describe(name: str) -> Optional[dict[str, str]]:
"""What to show the user when asking permission to install (OPE-85)."""
tool = MANAGED.get(name)
if not tool:
return None
dl = tool.downloads.get(_platform_key())
if not dl:
return None
parsed = urlparse(dl.url)
path_parts = [p for p in parsed.path.split("/") if p]
return {
"name": tool.name,
"version": tool.version,
"summary": tool.summary,
"url": dl.url,
"sha256": dl.sha256,
# Publisher, human-readable ("github.com/aquasecurity") — for the consent card.
"source": parsed.netloc + (f"/{path_parts[0]}" if path_parts else ""),
}
def _verify(blob: bytes, expected: str) -> None:
actual = hashlib.sha256(blob).hexdigest()
if actual != expected:
raise ValueError(
f"checksum mismatch: expected {expected}, got {actual} — refusing to install"
)
def install(name: str, *, timeout: int = 120) -> str:
"""Install a pinned tool and return its absolute path.
Only ever called after the user approves the request. The download is verified
against the pinned digest BEFORE anything is written to its final location, so a
tampered or truncated artifact never becomes an executable on disk.
"""
tool = MANAGED.get(name)
if not tool:
raise KeyError(f"{name} is not a managed tool")
dl = tool.downloads.get(_platform_key())
if not dl:
raise KeyError(f"{name} has no pinned build for {_platform_key()}")
target = _managed_path(tool)
if target.is_file() and os.access(target, os.X_OK):
return str(target)
with urllib.request.urlopen(dl.url, timeout=timeout) as resp: # noqa: S310 - pinned URL
blob = resp.read()
_verify(blob, dl.sha256)
target.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
if dl.member:
archive = tmp_path / "asset.tar.gz"
archive.write_bytes(blob)
with tarfile.open(archive) as tf:
extracted = tf.extractfile(dl.member)
if extracted is None:
raise ValueError(f"{dl.member} missing from {name} archive")
payload = extracted.read()
else:
payload = blob
staged = tmp_path / "binary"
staged.write_bytes(payload)
staged.chmod(staged.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
shutil.move(str(staged), str(target))
_link_into_bin(tool, target)
return str(target)
def _link_into_bin(tool: ManagedTool, target: Path) -> None:
"""Expose the versioned binary under the stable bin dir (PATH-friendly name)."""
link = bin_dir() / target.name
link.parent.mkdir(parents=True, exist_ok=True)
try:
if link.is_symlink() or link.exists():
link.unlink()
link.symlink_to(target)
except OSError:
# Filesystems without symlinks (some Windows setups): a copy serves the same role.
shutil.copy2(target, link)