blob: 24971ab6e39aa570639bd8f32e1a1e1d58f56df7 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Validate the hook-manager registry and write it into its implementations.
`tools/hook-manager/registry.json` is the source of truth for the git-hook
managers the secure agent setup integrates (the contract is
`tools/hook-manager/README.md`). The scripts that implement the contract run
where the registry is not available (a copy under `~/.claude/bin`, a hook
under `~/.claude/git-hooks`, a git hook that must start fast), so each carries
a generated block between `BEGIN generated by tools/dev/sync-hook-managers.py`
and `END ...` markers:
- `tools/agent-isolation/hook-manager-shim.sh`: the install rewrite per tool.
- `tools/agent-isolation/git-hook-dispatcher.sh`: the host-only home and
run-time override per tool.
- `tools/sandbox-lint/src/sandbox_lint/__init__.py`: the homes the lint checks.
It also checks, without writing, that the committed settings and the
sandbox-lint baseline deny every manager's default home and give the sandbox
home of every manager this repository uses.
Run as a prek hook. Rewrites the blocks in place and exits 1 if one changed
(re-stage and commit again); `--check` reports drift without writing.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[2]
REGISTRY = Path("tools/hook-manager/registry.json")
SHIM = Path("tools/agent-isolation/hook-manager-shim.sh")
DISPATCHER = Path("tools/agent-isolation/git-hook-dispatcher.sh")
SANDBOX_LINT = Path("tools/sandbox-lint/src/sandbox_lint/__init__.py")
SETTINGS = (Path(".claude/settings.json"), Path("tools/sandbox-lint/expected.json"))
#: Install strategies the shim implements. A new one lands with its shim branch.
STRATEGIES = {"git-dir-flag", "hooks-path-env", "hide-global-config", "none"}
#: How the shim recognises a positional install action: `exact` (argv[0] is the
#: action) or `prefix` (a non-option argument prefixes it, as a Symfony Console
#: application resolves an unambiguous abbreviation after global options).
INSTALL_MATCHES = {"exact", "prefix"}
_NAME = re.compile(r"^[a-z][a-z0-9-]*$")
_SUBCOMMAND = re.compile(r"^(?:--)?[a-z][a-z0-9-]*$")
_ALIAS = re.compile(r"^--?[a-z][a-z0-9-]*$")
_ENV = re.compile(r"^[A-Z][A-Z0-9_]*$")
_FLAG = re.compile(r"^--[a-z][a-z0-9-]*$")
_DIR = re.compile(r"^[a-z][a-z0-9.-]*$")
_FILE = re.compile(r"^[A-Za-z0-9._-]+$")
_MARKER = re.compile(r"^[A-Za-z0-9._-]+$")
_BLOCK = re.compile(
r"(?P<begin>^[ \t]*# BEGIN generated by tools/dev/sync-hook-managers\.py; do not edit\n)"
r".*?"
r"(?P<end>^[ \t]*# END generated by tools/dev/sync-hook-managers\.py$)",
re.MULTILINE | re.DOTALL,
)
def validate(registry: dict[str, Any]) -> list[str]:
"""Every rule of the contract's registry section, as error strings."""
errors: list[str] = []
adapters = registry.get("adapters")
if not isinstance(adapters, dict) or not adapters:
return ["adapters: missing or empty"]
for name, adapter in adapters.items():
where = f"adapters.{name}"
if not _NAME.match(name):
errors.append(f"{where}: the name must be the binary's name ([a-z][a-z0-9-]*)")
if not isinstance(adapter, dict):
errors.append(f"{where}: must be an object")
continue
for key in ("language", "upstream"):
if not isinstance(adapter.get(key), str) or not adapter[key]:
errors.append(f"{where}.{key}: missing")
detect = adapter.get("detect")
if not isinstance(detect, dict):
errors.append(f"{where}.detect: missing")
else:
files = detect.get("config_files")
if not isinstance(files, list) or not all(isinstance(f, str) and _FILE.match(f) for f in files):
errors.append(f"{where}.detect.config_files: a list of file names")
elif not files and adapter.get("home"):
errors.append(
f"{where}.detect.config_files: a manager with a home must have root config_files"
)
if not isinstance(detect.get("nested"), bool):
errors.append(f"{where}.detect.nested: whether a config below the root counts")
if not isinstance(detect.get("hook_marker"), str) or not _MARKER.match(detect["hook_marker"]):
errors.append(f"{where}.detect.hook_marker: the text an installed hook that runs it contains")
install = adapter.get("install")
if not isinstance(install, dict):
errors.append(f"{where}.install: missing")
else:
strategy = install.get("strategy")
if strategy not in STRATEGIES:
errors.append(
f"{where}.install.strategy: {strategy!r} is not one the shim implements "
f"({', '.join(sorted(STRATEGIES))})"
)
if strategy != "none":
subcommand = install.get("subcommand")
if not isinstance(subcommand, str) or not _SUBCOMMAND.match(subcommand):
errors.append(f"{where}.install.subcommand: missing or not a word or --flag")
aliases = install.get("aliases")
if aliases is not None:
if (
not isinstance(aliases, list)
or not aliases
or not all(isinstance(alias, str) and _ALIAS.match(alias) for alias in aliases)
):
errors.append(f"{where}.install.aliases: a non-empty list of flags")
elif not isinstance(subcommand, str) or not subcommand.startswith("-"):
errors.append(f"{where}.install.aliases: only a flag-style subcommand takes aliases")
elif subcommand in aliases:
errors.append(f"{where}.install.aliases: must not repeat the subcommand")
if strategy == "git-dir-flag" and not (
isinstance(install.get("flag"), str) and _FLAG.match(install["flag"])
):
errors.append(
f"{where}.install.flag: git-dir-flag needs the long option that takes the git dir"
)
elif strategy != "git-dir-flag" and "flag" in install:
errors.append(f"{where}.install.flag: strategy {strategy!r} takes no flag")
match = install.get("match", "exact")
if match not in INSTALL_MATCHES:
errors.append(
f"{where}.install.match: {match!r} is not how the shim finds the install "
f"action ({', '.join(sorted(INSTALL_MATCHES))})"
)
elif match == "prefix" and not (
isinstance(subcommand, str) and not subcommand.startswith("-")
):
errors.append(f"{where}.install.match: prefix matching needs a positional subcommand")
if "vendor_bin" in install and not isinstance(install["vendor_bin"], bool):
errors.append(f"{where}.install.vendor_bin: must be true or false")
else:
for key in ("flag", "aliases", "match", "vendor_bin"):
if key in install:
errors.append(f"{where}.install.{key}: strategy 'none' takes no {key}")
if "runtime_env" in install and not isinstance(install["runtime_env"], bool):
errors.append(f"{where}.install.runtime_env: must be true or false")
if install.get("runtime_env") and strategy != "hooks-path-env":
errors.append(
f"{where}.install.runtime_env: only hooks-path-env needs the override at run time"
)
home = adapter.get("home")
if home is None:
continue # a manager that keeps nothing outside the repository
if not isinstance(home, dict):
errors.append(f"{where}.home: must be an object")
continue
if not isinstance(home.get("env"), str) or not _ENV.match(home["env"]):
errors.append(f"{where}.home.env: the environment variable that relocates the home")
if home.get("expands_tilde") is not True:
# The committed settings can only spell a per-user path with `~`.
errors.append(
f"{where}.home.expands_tilde: a tool that does not expand ~ needs a per-user sandbox home"
)
default, sandbox, host = home.get("default"), home.get("sandbox"), home.get("host")
for key, value in (("default", default), ("sandbox", sandbox)):
if not isinstance(value, str) or not value.startswith("~/.cache/"):
errors.append(f"{where}.home.{key}: must sit under ~/.cache/")
if (
isinstance(default, str)
and isinstance(sandbox, str)
and (sandbox == default or sandbox.startswith(default + "/") or default.startswith(sandbox + "/"))
):
errors.append(f"{where}.home.sandbox: must not overlap the default home {default}")
if not isinstance(host, str) or not host.startswith("~/.local/share/") or not _DIR.match(host[15:]):
errors.append(
f"{where}.home.host: a single directory under ~/.local/share/, which no sandbox grant covers"
)
return errors
def render(registry: dict[str, Any]) -> dict[Path, str]:
"""The body of each generated block."""
adapters = registry["adapters"]
cases = []
for name, adapter in sorted(adapters.items()):
install = adapter["install"]
if install.get("strategy") == "none":
continue
fields = [f"install_subcommand={install['subcommand']}", f"install_strategy={install['strategy']}"]
if "aliases" in install:
actions = " ".join([install["subcommand"], *install["aliases"]])
fields.append(f"install_aliases='{actions}'")
if "flag" in install:
fields.append(f"install_flag={install['flag']}")
if install.get("match") == "prefix":
fields.append("install_match=prefix")
if install.get("vendor_bin"):
fields.append("install_vendor_bin=1")
cases.append(f" {name}) {'; '.join(fields)} ;;\n")
shim = 'adapter() {\n case "$1" in\n' + "".join(cases) + " *) return 1 ;;\n esac\n}\n"
homes = [(n, a) for n, a in sorted(adapters.items()) if a.get("home")]
runtime = [
f"{n}:{a['detect']['hook_marker']}"
for n, a in sorted(adapters.items())
if a["install"].get("runtime_env")
]
dispatcher = (
'hook_manager_homes="'
+ " ".join(f"{a['home']['env']}={a['home']['host'][15:]}" for _, a in homes)
+ '"\n'
+ 'hook_manager_runtime_envs="'
+ " ".join(runtime)
+ '"\n'
)
lint = "HOOK_MANAGER_HOMES = (\n"
for name, a in homes:
home, detect = a["home"], a["detect"]
files = ", ".join(json.dumps(f) for f in detect["config_files"])
files = f"({files},)" if len(detect["config_files"]) == 1 else f"({files})"
fields = [
json.dumps(name),
json.dumps(home["env"]),
json.dumps(home["default"]),
json.dumps(home["sandbox"]),
]
fields += [files, str(detect["nested"]), json.dumps(detect["hook_marker"])]
lint += " (\n" + "".join(f" {f},\n" for f in fields) + " ),\n"
lint += ")\n"
return {SHIM: shim, DISPATCHER: dispatcher, SANDBOX_LINT: lint}
def check_settings(registry: dict[str, Any], root: Path) -> list[str]:
"""The committed settings give each sandbox home and deny each default home.
The deny for every manager (the default home is per-user); the sandbox
home only for the managers this repository uses (a config file at its
root), as sandbox-lint requires it only there.
"""
errors = []
for rel in SETTINGS:
settings = json.loads((root / rel).read_text(encoding="utf-8"))
env = settings.get("env") or {}
deny = (settings.get("permissions") or {}).get("deny") or []
for name, adapter in sorted(registry["adapters"].items()):
home = adapter.get("home")
if not home:
continue
used = any((root / f).is_file() for f in adapter["detect"]["config_files"])
if used and env.get(home["env"]) != home["sandbox"]:
errors.append(f"{rel}: env.{home['env']} must be {home['sandbox']!r} ({name})")
if f"Edit({home['default']}/**)" not in deny:
errors.append(f"{rel}: permissions.deny must carry Edit({home['default']}/**) ({name})")
return errors
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--check", action="store_true", help="report drift without writing")
parser.add_argument("--root", type=Path, default=REPO_ROOT, help=argparse.SUPPRESS)
args = parser.parse_args(argv)
root: Path = args.root
registry = json.loads((root / REGISTRY).read_text(encoding="utf-8"))
errors = validate(registry)
if errors:
for error in errors:
print(f"{REGISTRY}: {error}", file=sys.stderr)
return 1
status = 0
for rel, body in render(registry).items():
path = root / rel
text = path.read_text(encoding="utf-8")
blocks = list(_BLOCK.finditer(text))
if len(blocks) != 1:
print(f"{rel}: expected exactly one generated block, found {len(blocks)}", file=sys.stderr)
status = 1
continue
block = blocks[0]
new = text[: block.end("begin")] + body + text[block.start("end") :]
if new != text:
status = 1
if args.check:
print(f"{rel}: generated block is out of date; run {Path(__file__).name}", file=sys.stderr)
else:
path.write_text(new, encoding="utf-8")
print(f"{rel}: regenerated from {REGISTRY}", file=sys.stderr)
for error in check_settings(registry, root):
print(error, file=sys.stderr)
status = 1
return status
if __name__ == "__main__":
sys.exit(main())