exitzero 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exitzero/__init__.py +3 -0
- exitzero/__main__.py +3 -0
- exitzero/api.py +69 -0
- exitzero/cli.py +208 -0
- exitzero/files.py +51 -0
- exitzero/hooks.py +130 -0
- exitzero/ledger.py +25 -0
- exitzero/loader.py +19 -0
- exitzero/policy.py +171 -0
- exitzero/runner.py +102 -0
- exitzero-0.1.0.dist-info/METADATA +234 -0
- exitzero-0.1.0.dist-info/RECORD +20 -0
- exitzero-0.1.0.dist-info/WHEEL +5 -0
- exitzero-0.1.0.dist-info/entry_points.txt +6 -0
- exitzero-0.1.0.dist-info/licenses/LICENSE +21 -0
- exitzero-0.1.0.dist-info/top_level.txt +5 -0
- exitzero_harness/__init__.py +248 -0
- exitzero_ledger/__init__.py +12 -0
- exitzero_mcp_gateway/__init__.py +12 -0
- exitzero_verify/__init__.py +459 -0
exitzero/__init__.py
ADDED
exitzero/__main__.py
ADDED
exitzero/api.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Small, versioned contracts shared by core and plugins."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable, Any
|
|
6
|
+
|
|
7
|
+
API_VERSION = 1
|
|
8
|
+
HOOK_SLOTS = frozenset({"PreToolUse", "PostToolUse", "pre-commit", "CI"})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Finding:
|
|
13
|
+
rule: str
|
|
14
|
+
message: str
|
|
15
|
+
path: str | None = None
|
|
16
|
+
line: int | None = None
|
|
17
|
+
severity: str = "error"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CheckSpec:
|
|
22
|
+
id: str
|
|
23
|
+
kind: str
|
|
24
|
+
paths: tuple[str, ...] = ()
|
|
25
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Context:
|
|
30
|
+
root: Path
|
|
31
|
+
policy: dict[str, Any]
|
|
32
|
+
policy_path: Path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
CheckHandler = Callable[[Context, CheckSpec], list[Finding]]
|
|
36
|
+
LintHandler = Callable[[Context], list[Finding]]
|
|
37
|
+
HookHandler = Callable[[Context, str], list[Finding]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Registry:
|
|
42
|
+
"""Plugins call register(); duplicate names and unsupported slots fail closed."""
|
|
43
|
+
|
|
44
|
+
checks: dict[str, CheckHandler] = field(default_factory=dict)
|
|
45
|
+
linters: dict[str, LintHandler] = field(default_factory=dict)
|
|
46
|
+
hooks: dict[str, list[HookHandler]] = field(default_factory=dict)
|
|
47
|
+
commands: dict[str, Callable[[Context, list[str]], int]] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def add_check(self, name: str, handler: CheckHandler) -> None:
|
|
50
|
+
self._add(self.checks, name, handler)
|
|
51
|
+
|
|
52
|
+
def add_linter(self, name: str, handler: LintHandler) -> None:
|
|
53
|
+
self._add(self.linters, name, handler)
|
|
54
|
+
|
|
55
|
+
def add_hook(self, slot: str, handler: HookHandler) -> None:
|
|
56
|
+
if slot not in HOOK_SLOTS:
|
|
57
|
+
raise ValueError("Unsupported hook slot")
|
|
58
|
+
self.hooks.setdefault(slot, []).append(handler)
|
|
59
|
+
|
|
60
|
+
def add_command(self, name: str, handler: Callable[[Context, list[str]], int]) -> None:
|
|
61
|
+
if name in {"init", "check", "lint-config", "hooks", "report", "plugin"}:
|
|
62
|
+
raise ValueError("Reserved CLI command")
|
|
63
|
+
self._add(self.commands, name, handler)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _add(target: dict, name: str, handler: Callable) -> None:
|
|
67
|
+
if not name or name in target or not callable(handler):
|
|
68
|
+
raise ValueError("Invalid or duplicate plugin registration")
|
|
69
|
+
target[name] = handler
|
exitzero/cli.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Public command-line interface; check outcomes always come from the runner."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .api import Context, HOOK_SLOTS
|
|
10
|
+
from .files import safe_path, select_files, validate_relative
|
|
11
|
+
from .hooks import CURSOR_EVENTS, cursor_response, install
|
|
12
|
+
from .loader import discover
|
|
13
|
+
from .policy import DEFAULT_POLICY, load_policy, python_profile_policy, sync_agents
|
|
14
|
+
from .runner import run
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parser() -> argparse.ArgumentParser:
|
|
18
|
+
cli = argparse.ArgumentParser(prog="exitzero")
|
|
19
|
+
cli.add_argument("--version", action="version", version=__version__)
|
|
20
|
+
cli.add_argument("--root", default=".", help="Repository directory")
|
|
21
|
+
cli.add_argument("--policy", default="exitzero.toml", help="Policy path relative to root")
|
|
22
|
+
commands = cli.add_subparsers(dest="command", required=True)
|
|
23
|
+
init = commands.add_parser("init", help="Create policy and managed AGENTS section")
|
|
24
|
+
init.add_argument("--sync", action="store_true", help="Regenerate only the managed AGENTS section")
|
|
25
|
+
init.add_argument("--profile", choices=("default", "python"), default="default",
|
|
26
|
+
help="Policy starter profile (python adds static checks and command slots)")
|
|
27
|
+
init.add_argument("--source-root", action="append", default=[], metavar="PATH",
|
|
28
|
+
help="Python source root; repeat for multiple roots")
|
|
29
|
+
init.add_argument("--allow-module", action="append", default=[], metavar="MODULE",
|
|
30
|
+
help="External Python module trusted by the imports check")
|
|
31
|
+
init.add_argument("--test-command", metavar="COMMAND",
|
|
32
|
+
help="Shell-free test command to store as argv")
|
|
33
|
+
init.add_argument("--review-command", action="append", default=[], metavar="COMMAND",
|
|
34
|
+
help="Shell-free review command; repeatable")
|
|
35
|
+
for name in ("check", "lint-config"):
|
|
36
|
+
child = commands.add_parser(name)
|
|
37
|
+
child.add_argument("--format", choices=("human", "json"), default="human")
|
|
38
|
+
hooks = commands.add_parser("hooks").add_subparsers(dest="hook_command", required=True)
|
|
39
|
+
installer = hooks.add_parser("install")
|
|
40
|
+
installer.add_argument("--adapter", choices=("cursor", "pre-commit"), default="cursor")
|
|
41
|
+
hook_run = hooks.add_parser("run")
|
|
42
|
+
hook_run.add_argument("--adapter", choices=("generic", "cursor"), default="generic")
|
|
43
|
+
hook_run.add_argument("--slot", choices=sorted(HOOK_SLOTS), default="CI")
|
|
44
|
+
hook_run.add_argument("--event", choices=sorted(CURSOR_EVENTS), default="stop")
|
|
45
|
+
hook_run.add_argument("--format", choices=("human", "json"), default="human")
|
|
46
|
+
report = commands.add_parser("report")
|
|
47
|
+
report.add_argument("--format", choices=("human", "json"), default="human")
|
|
48
|
+
plugin = commands.add_parser("plugin")
|
|
49
|
+
plugin.add_argument("name")
|
|
50
|
+
plugin.add_argument("args", nargs=argparse.REMAINDER)
|
|
51
|
+
return cli
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def emit(receipt: dict, output: str) -> None:
|
|
55
|
+
if output == "json":
|
|
56
|
+
print(json.dumps(receipt, ensure_ascii=False, sort_keys=True))
|
|
57
|
+
return
|
|
58
|
+
print(f"exitzero: {receipt['status']} (exit {receipt['exit_code']})")
|
|
59
|
+
for finding in receipt["findings"]:
|
|
60
|
+
location = f" {finding['path']}" if finding.get("path") else ""
|
|
61
|
+
if finding.get("line"):
|
|
62
|
+
location += f":{finding['line']}"
|
|
63
|
+
print(f" {finding['rule']}{location}: {finding['message']}")
|
|
64
|
+
print(f"Receipt: {receipt['receipt'] or 'NOT WRITTEN'}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_COMMAND_OPERATORS = frozenset("|&;<>()")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_init_command(value: str | None) -> list[str] | None:
|
|
71
|
+
"""Parse a user command without invoking a shell or accepting operators."""
|
|
72
|
+
|
|
73
|
+
if value is None:
|
|
74
|
+
return None
|
|
75
|
+
if not isinstance(value, str) or not value.strip() or "\x00" in value:
|
|
76
|
+
raise ValueError("command must be a non-empty string")
|
|
77
|
+
quote: str | None = None
|
|
78
|
+
escaped = False
|
|
79
|
+
for character in value:
|
|
80
|
+
if escaped:
|
|
81
|
+
escaped = False
|
|
82
|
+
continue
|
|
83
|
+
if quote is not None:
|
|
84
|
+
if character == quote:
|
|
85
|
+
quote = None
|
|
86
|
+
elif character == "\\" and quote == '"':
|
|
87
|
+
escaped = True
|
|
88
|
+
continue
|
|
89
|
+
if character in "'\"":
|
|
90
|
+
quote = character
|
|
91
|
+
elif character == "\\":
|
|
92
|
+
escaped = True
|
|
93
|
+
elif character in _COMMAND_OPERATORS or character in "\r\n":
|
|
94
|
+
raise ValueError("commands cannot contain shell operators or control characters")
|
|
95
|
+
if quote is not None or escaped:
|
|
96
|
+
raise ValueError("command has an unterminated quote or escape")
|
|
97
|
+
try:
|
|
98
|
+
argv = shlex.split(value, posix=True)
|
|
99
|
+
except ValueError as error:
|
|
100
|
+
raise ValueError("command has invalid shell-style quoting") from error
|
|
101
|
+
if not argv or any(not isinstance(item, str) or not item for item in argv):
|
|
102
|
+
raise ValueError("command must contain at least one argument")
|
|
103
|
+
return argv
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _init_generation(args: argparse.Namespace) -> str:
|
|
107
|
+
generation_requested = bool(
|
|
108
|
+
args.profile != "default" or args.source_root or args.allow_module
|
|
109
|
+
or args.test_command is not None or args.review_command
|
|
110
|
+
)
|
|
111
|
+
if args.sync and generation_requested:
|
|
112
|
+
raise ValueError("init --sync cannot be combined with policy generation options")
|
|
113
|
+
if args.profile == "default" and generation_requested:
|
|
114
|
+
raise ValueError("--profile python is required with generation options")
|
|
115
|
+
if args.profile != "python":
|
|
116
|
+
return DEFAULT_POLICY
|
|
117
|
+
roots = args.source_root or ["."]
|
|
118
|
+
for root in roots:
|
|
119
|
+
validate_relative(root)
|
|
120
|
+
if any(character in root for character in "*?[]"):
|
|
121
|
+
raise ValueError("source roots must be directories, not glob patterns")
|
|
122
|
+
for module in args.allow_module:
|
|
123
|
+
if not module or any(not part.isidentifier() for part in module.split(".")):
|
|
124
|
+
raise ValueError("allow modules must contain dotted module names")
|
|
125
|
+
test_argv = _parse_init_command(args.test_command)
|
|
126
|
+
review_argvs: list[list[str]] = []
|
|
127
|
+
for value in args.review_command:
|
|
128
|
+
argv = _parse_init_command(value)
|
|
129
|
+
if argv is None:
|
|
130
|
+
raise ValueError("review commands must be non-empty strings")
|
|
131
|
+
review_argvs.append(argv)
|
|
132
|
+
return python_profile_policy(roots, args.allow_module, test_argv, review_argvs)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main(argv: list[str] | None = None) -> int:
|
|
136
|
+
args = parser().parse_args(argv)
|
|
137
|
+
root = Path(args.root).resolve()
|
|
138
|
+
if args.command in {"check", "lint-config"}:
|
|
139
|
+
receipt = run(root, args.policy, args.command)
|
|
140
|
+
emit(receipt, args.format)
|
|
141
|
+
return receipt["exit_code"]
|
|
142
|
+
if args.command == "hooks" and args.hook_command == "run":
|
|
143
|
+
if args.adapter == "cursor":
|
|
144
|
+
try:
|
|
145
|
+
payload = json.loads(sys.stdin.read(1024 * 1024))
|
|
146
|
+
if not isinstance(payload, dict) or type(payload.get("loop_count", 0)) is not int or payload.get("loop_count", 0) < 0:
|
|
147
|
+
raise ValueError("Invalid Cursor payload")
|
|
148
|
+
except (ValueError, OSError):
|
|
149
|
+
# Still execute and persist the actual gate outcome for every invocation.
|
|
150
|
+
receipt = run(root, args.policy, "check", CURSOR_EVENTS[args.event], input_error=True)
|
|
151
|
+
print(json.dumps({"error": "Invalid Cursor JSON input", "receipt": receipt["receipt"]}))
|
|
152
|
+
return 2
|
|
153
|
+
receipt = run(root, args.policy, "check", CURSOR_EVENTS[args.event])
|
|
154
|
+
print(json.dumps(cursor_response(receipt, args.event, payload)))
|
|
155
|
+
# Cursor reads the JSON protocol; generic adapters expose unchanged gate codes.
|
|
156
|
+
return 0 if receipt["exit_code"] != 2 else 2
|
|
157
|
+
receipt = run(root, args.policy, "check", args.slot)
|
|
158
|
+
emit(receipt, args.format)
|
|
159
|
+
return receipt["exit_code"]
|
|
160
|
+
try:
|
|
161
|
+
path = safe_path(root, args.policy)
|
|
162
|
+
if args.command == "init":
|
|
163
|
+
if not root.is_dir():
|
|
164
|
+
raise ValueError("Root directory must exist")
|
|
165
|
+
generated_policy = _init_generation(args)
|
|
166
|
+
if not args.sync:
|
|
167
|
+
if path.exists():
|
|
168
|
+
raise ValueError("Policy already exists; use init --sync")
|
|
169
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
path.write_text(generated_policy, encoding="utf-8")
|
|
171
|
+
ignore = safe_path(root, ".gitignore")
|
|
172
|
+
existing = ignore.read_text(encoding="utf-8") if ignore.exists() else ""
|
|
173
|
+
additions = [item for item in ("/.exitzero/", "__pycache__/") if item not in existing.splitlines()]
|
|
174
|
+
if additions:
|
|
175
|
+
ignore.write_text(existing.rstrip() + ("\n" if existing else "") + "\n".join(additions) + "\n", encoding="utf-8")
|
|
176
|
+
if not select_files(root, ["**/*.py"]):
|
|
177
|
+
example = safe_path(root, "exitzero_sample.py")
|
|
178
|
+
example.write_text('"""Replace this sample with your project checks."""\n\ndef add(left: int, right: int) -> int:\n return left + right\n', encoding="utf-8")
|
|
179
|
+
policy = load_policy(path)
|
|
180
|
+
sync_agents(root, policy)
|
|
181
|
+
print("Policy and AGENTS.md synchronized. Run exitzero check to verify.")
|
|
182
|
+
return 0
|
|
183
|
+
if args.command == "report":
|
|
184
|
+
directory = safe_path(root, ".exitzero/runs")
|
|
185
|
+
files = sorted(directory.glob("*.json"), key=lambda p: p.stat().st_mtime_ns)
|
|
186
|
+
if not files:
|
|
187
|
+
raise ValueError("No receipt exists; run check first")
|
|
188
|
+
latest = safe_path(root, files[-1].relative_to(root).as_posix())
|
|
189
|
+
emit(json.loads(latest.read_text(encoding="utf-8")), args.format)
|
|
190
|
+
return 0
|
|
191
|
+
policy = load_policy(path)
|
|
192
|
+
if args.command == "hooks":
|
|
193
|
+
print("Installed " + install(root, path, args.adapter) + ". Run lint-config to verify configuration.")
|
|
194
|
+
return 0
|
|
195
|
+
registry = discover(policy["plugins"])
|
|
196
|
+
if args.name not in registry.commands:
|
|
197
|
+
raise ValueError("No such plugin command")
|
|
198
|
+
result = registry.commands[args.name](Context(root, policy, path), args.args)
|
|
199
|
+
if type(result) is not int or result not in (0, 1, 2):
|
|
200
|
+
raise ValueError("Plugin command must return exit code 0, 1 or 2")
|
|
201
|
+
return result
|
|
202
|
+
except Exception as error:
|
|
203
|
+
print(f"exitzero: unable to complete command ({type(error).__name__}); check policy, paths and existing files.", file=sys.stderr)
|
|
204
|
+
return 2
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
raise SystemExit(main())
|
exitzero/files.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Repository-scoped reads that avoid credentials and generated directories."""
|
|
2
|
+
from pathlib import Path, PurePosixPath
|
|
3
|
+
|
|
4
|
+
EXCLUDED = frozenset({".git", ".venv", "venv", "node_modules", "__pycache__", ".exitzero", "build", "dist"})
|
|
5
|
+
SENSITIVE_DIRS = frozenset({".ssh", ".aws", ".gnupg"})
|
|
6
|
+
SENSITIVE_NAMES = frozenset({"auth.json", "credentials", "credentials.json", "tokens.json", "token.json", "id_rsa", "id_ed25519"})
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_relative(value: str) -> None:
|
|
10
|
+
if (not isinstance(value, str) or not value or "\\" in value or "\x00" in value
|
|
11
|
+
or PurePosixPath(value).is_absolute() or ".." in PurePosixPath(value).parts
|
|
12
|
+
or ":" in value):
|
|
13
|
+
raise ValueError("Expected a relative repository path without traversal")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_sensitive(path: Path) -> bool:
|
|
17
|
+
name = path.name.lower()
|
|
18
|
+
return (name == ".env" or name.startswith(".env.") or name in SENSITIVE_NAMES
|
|
19
|
+
or path.suffix.lower() in {".pem", ".key", ".p12", ".pfx", ".keystore"}
|
|
20
|
+
or any(part.lower() in SENSITIVE_DIRS for part in path.parts))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def safe_path(root: Path, relative: str) -> Path:
|
|
24
|
+
validate_relative(relative)
|
|
25
|
+
candidate = root / relative
|
|
26
|
+
if is_sensitive(Path(relative)):
|
|
27
|
+
raise ValueError("Credential-like paths are not valid inspection targets")
|
|
28
|
+
cursor = root
|
|
29
|
+
for part in Path(relative).parts:
|
|
30
|
+
cursor = cursor / part
|
|
31
|
+
if cursor.is_symlink():
|
|
32
|
+
raise ValueError("Symlinks are not valid inspection targets")
|
|
33
|
+
if not candidate.resolve().is_relative_to(root.resolve()):
|
|
34
|
+
raise ValueError("Path escapes repository")
|
|
35
|
+
return candidate
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def select_files(root: Path, patterns: tuple[str, ...] | list[str]) -> list[Path]:
|
|
39
|
+
selected: set[Path] = set()
|
|
40
|
+
for pattern in patterns:
|
|
41
|
+
validate_relative(pattern)
|
|
42
|
+
if is_sensitive(Path(pattern)):
|
|
43
|
+
raise ValueError("Credential-like paths are not valid inspection targets")
|
|
44
|
+
for candidate in root.glob(pattern):
|
|
45
|
+
relative = candidate.relative_to(root)
|
|
46
|
+
if any(part in EXCLUDED for part in relative.parts) or is_sensitive(relative):
|
|
47
|
+
continue
|
|
48
|
+
safe_path(root, relative.as_posix())
|
|
49
|
+
if candidate.is_file():
|
|
50
|
+
selected.add(candidate)
|
|
51
|
+
return sorted(selected)
|
exitzero/hooks.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""IDE adapters delegate to the shared runner and preserve existing hooks."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .api import Context, Finding
|
|
10
|
+
from .files import safe_path
|
|
11
|
+
|
|
12
|
+
CURSOR_EVENTS = {
|
|
13
|
+
"preToolUse": "PreToolUse",
|
|
14
|
+
"postToolUse": "PostToolUse",
|
|
15
|
+
"beforeShellExecution": "PreToolUse",
|
|
16
|
+
"beforeMCPExecution": "PreToolUse",
|
|
17
|
+
"afterFileEdit": "PostToolUse",
|
|
18
|
+
"stop": "PostToolUse",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _launcher() -> list[str]:
|
|
23
|
+
checkout = Path(__file__).resolve().parents[4] / "bin" / "exitzero"
|
|
24
|
+
return [sys.executable, str(checkout)] if checkout.is_file() else [sys.executable, "-m", "exitzero"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def expected_cursor_command(root: Path, policy_path: Path, slot: str = "PostToolUse") -> str:
|
|
28
|
+
event = "stop" if slot == "PostToolUse" else "preToolUse"
|
|
29
|
+
return shlex.join([*_launcher(), "--root", str(root), "--policy", str(policy_path.relative_to(root)),
|
|
30
|
+
"hooks", "run", "--adapter", "cursor", "--event", event])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _manifest(root: Path) -> dict:
|
|
34
|
+
path = safe_path(root, ".exitzero/hooks.json")
|
|
35
|
+
if path.exists():
|
|
36
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
37
|
+
if (not isinstance(value, dict) or type(value.get("version")) is not int
|
|
38
|
+
or value["version"] != 1 or not isinstance(value.get("files"), dict)
|
|
39
|
+
or any(not isinstance(digest, str) or len(digest) != 64 for digest in value["files"].values())):
|
|
40
|
+
raise ValueError("Invalid hook installation manifest")
|
|
41
|
+
return value
|
|
42
|
+
return {"version": 1, "files": {}}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def installed_inputs(root: Path) -> list[str]:
|
|
46
|
+
"""Include exactly the hook files inspected by the configuration linter."""
|
|
47
|
+
return [".exitzero/hooks.json", *_manifest(root)["files"]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cursor_hook_error(entry: object) -> str | None:
|
|
51
|
+
"""Validate execution fields shared by Cursor installation and config lint."""
|
|
52
|
+
if not isinstance(entry, dict):
|
|
53
|
+
return "Cursor hook entries must be objects"
|
|
54
|
+
kind = entry.get("type", "command")
|
|
55
|
+
if kind not in ("command", "prompt"):
|
|
56
|
+
return "Cursor hook type must be command or prompt"
|
|
57
|
+
field = "prompt" if kind == "prompt" else "command"
|
|
58
|
+
if not isinstance(entry.get(field), str) or not entry[field].strip():
|
|
59
|
+
return f"Cursor hook entries require a non-empty {field}"
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def install(root: Path, policy_path: Path, adapter: str) -> str:
|
|
64
|
+
manifest = _manifest(root)
|
|
65
|
+
if adapter == "cursor":
|
|
66
|
+
relative = ".cursor/hooks.json"
|
|
67
|
+
path = safe_path(root, relative)
|
|
68
|
+
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"version": 1, "hooks": {}}
|
|
69
|
+
if (not isinstance(data, dict) or type(data.get("version")) is not int
|
|
70
|
+
or data["version"] != 1 or not isinstance(data.get("hooks"), dict)):
|
|
71
|
+
raise ValueError("Invalid existing Cursor hook configuration")
|
|
72
|
+
hooks = data["hooks"].setdefault("stop", [])
|
|
73
|
+
if not isinstance(hooks, list) or any(cursor_hook_error(h) for h in hooks):
|
|
74
|
+
raise ValueError("Invalid existing Cursor stop hooks")
|
|
75
|
+
command = expected_cursor_command(root, policy_path)
|
|
76
|
+
owned = next((h for h in hooks if h.get("type", "command") == "command" and h.get("command") == command), None)
|
|
77
|
+
if owned is None:
|
|
78
|
+
hooks.append({"command": command, "loop_limit": 1})
|
|
79
|
+
else:
|
|
80
|
+
owned.setdefault("loop_limit", 1)
|
|
81
|
+
content = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
82
|
+
else:
|
|
83
|
+
# Git executes hooks relative to the worktree root. Respect custom hook paths.
|
|
84
|
+
result = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-path", "hooks/pre-commit"],
|
|
85
|
+
capture_output=True, text=True, check=False)
|
|
86
|
+
if result.returncode != 0:
|
|
87
|
+
raise ValueError("pre-commit adapter requires a Git repository")
|
|
88
|
+
candidate = Path(result.stdout.strip())
|
|
89
|
+
candidate = candidate if candidate.is_absolute() else root / candidate
|
|
90
|
+
try:
|
|
91
|
+
relative = candidate.relative_to(root).as_posix()
|
|
92
|
+
except ValueError:
|
|
93
|
+
raise ValueError("External Git hook directories require manual CLI installation") from None
|
|
94
|
+
path = safe_path(root, relative)
|
|
95
|
+
command = shlex.join([*_launcher(), "--root", str(root), "--policy", policy_path.relative_to(root).as_posix(),
|
|
96
|
+
"hooks", "run", "--slot", "pre-commit"])
|
|
97
|
+
content = "#!/bin/sh\n# exitzero managed hook\nexec " + command + "\n"
|
|
98
|
+
if path.exists() and path.read_text(encoding="utf-8") != content:
|
|
99
|
+
raise ValueError("Existing pre-commit hook preserved; chain the CLI manually")
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
path.write_text(content, encoding="utf-8")
|
|
102
|
+
if adapter == "pre-commit":
|
|
103
|
+
path.chmod(path.stat().st_mode | 0o111)
|
|
104
|
+
manifest["files"][relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
105
|
+
target = safe_path(root, ".exitzero/hooks.json")
|
|
106
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
target.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
|
108
|
+
return relative
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def lint_installed(context: Context) -> list[Finding]:
|
|
112
|
+
manifest = _manifest(context.root)
|
|
113
|
+
findings = []
|
|
114
|
+
for relative, digest in manifest["files"].items():
|
|
115
|
+
path = safe_path(context.root, relative)
|
|
116
|
+
if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
117
|
+
findings.append(Finding("harness.hooks-drift", "Installed hook changed or disappeared; review and reinstall it.", relative))
|
|
118
|
+
return findings
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cursor_response(receipt: dict, event: str, payload: dict) -> dict:
|
|
122
|
+
passed = receipt["exit_code"] == 0
|
|
123
|
+
if event in {"preToolUse", "beforeShellExecution", "beforeMCPExecution"}:
|
|
124
|
+
return {"permission": "allow" if passed else "deny", "user_message": "exitzero: " + receipt["status"],
|
|
125
|
+
"agent_message": "Review the exitzero run receipt." if not passed else ""}
|
|
126
|
+
if event == "postToolUse" and not passed:
|
|
127
|
+
return {"additional_context": "exitzero failed. Inspect the run receipt and fix the reported checks."}
|
|
128
|
+
if event == "stop" and not passed and payload.get("status") != "aborted" and payload.get("loop_count", 0) < 1:
|
|
129
|
+
return {"followup_message": "exitzero failed. Run exitzero check, inspect the receipt, and fix the reported checks."}
|
|
130
|
+
return {}
|
exitzero/ledger.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Append-only per-run receipts; no source, environment or command output."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
from .files import safe_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def persist(root: Path, receipt: dict) -> str:
|
|
10
|
+
directory = safe_path(root, ".exitzero/runs")
|
|
11
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
relative = f".exitzero/runs/{receipt['run_id']}.json"
|
|
13
|
+
destination = safe_path(root, relative)
|
|
14
|
+
receipt["receipt"] = relative
|
|
15
|
+
temporary: Path | None = None
|
|
16
|
+
try:
|
|
17
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=directory, prefix=".pending-", encoding="utf-8", delete=False) as handle:
|
|
18
|
+
temporary = Path(handle.name)
|
|
19
|
+
json.dump(receipt, handle, ensure_ascii=False, sort_keys=True, indent=2)
|
|
20
|
+
handle.write("\n")
|
|
21
|
+
temporary.replace(destination)
|
|
22
|
+
finally:
|
|
23
|
+
if temporary is not None:
|
|
24
|
+
temporary.unlink(missing_ok=True)
|
|
25
|
+
return relative
|
exitzero/loader.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Load only policy-selected plugins, by entry point or explicit module name."""
|
|
2
|
+
import importlib
|
|
3
|
+
from importlib.metadata import entry_points
|
|
4
|
+
|
|
5
|
+
from .api import API_VERSION, Registry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def discover(names: list[str]) -> Registry:
|
|
9
|
+
registry = Registry()
|
|
10
|
+
available = entry_points(group="exitzero.plugins")
|
|
11
|
+
for name in names:
|
|
12
|
+
matches = [entry for entry in available if entry.name == name]
|
|
13
|
+
if len(matches) > 1:
|
|
14
|
+
raise ValueError("Ambiguous plugin entry point")
|
|
15
|
+
plugin = matches[0].load() if matches else importlib.import_module(name)
|
|
16
|
+
if getattr(plugin, "API_VERSION", None) != API_VERSION:
|
|
17
|
+
raise ValueError("Unsupported plugin API version")
|
|
18
|
+
plugin.register(registry)
|
|
19
|
+
return registry
|