greenproof 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.
Files changed (52) hide show
  1. greenproof/__init__.py +5 -0
  2. greenproof/_version.py +3 -0
  3. greenproof/accept.py +142 -0
  4. greenproof/cache.py +85 -0
  5. greenproof/changeset.py +138 -0
  6. greenproof/cli.py +255 -0
  7. greenproof/errors.py +26 -0
  8. greenproof/findings.py +42 -0
  9. greenproof/github.py +155 -0
  10. greenproof/gitops.py +330 -0
  11. greenproof/hooks.py +423 -0
  12. greenproof/init/__init__.py +1 -0
  13. greenproof/init/agents_md.py +73 -0
  14. greenproof/init/ci_github.py +84 -0
  15. greenproof/init/command.py +226 -0
  16. greenproof/init/detect.py +387 -0
  17. greenproof/init/hooks/__init__.py +16 -0
  18. greenproof/init/hooks/_common.py +93 -0
  19. greenproof/init/hooks/claude.py +32 -0
  20. greenproof/init/hooks/codex.py +36 -0
  21. greenproof/init/hooks/cursor.py +24 -0
  22. greenproof/init/hooks/gemini.py +40 -0
  23. greenproof/init/write_policy.py +37 -0
  24. greenproof/pins.py +43 -0
  25. greenproof/pipeline.py +313 -0
  26. greenproof/policy.py +257 -0
  27. greenproof/py.typed +0 -0
  28. greenproof/registry.py +155 -0
  29. greenproof/report.py +309 -0
  30. greenproof/revert.py +260 -0
  31. greenproof/rules/__init__.py +37 -0
  32. greenproof/rules/_common.py +49 -0
  33. greenproof/rules/ci.py +183 -0
  34. greenproof/rules/collect.py +101 -0
  35. greenproof/rules/hardcoded.py +284 -0
  36. greenproof/rules/parse.py +73 -0
  37. greenproof/rules/runner.py +664 -0
  38. greenproof/rules/self_mocked.py +366 -0
  39. greenproof/rules/suppress.py +108 -0
  40. greenproof/rules/tests_integrity.py +567 -0
  41. greenproof/rules/tests_lines.py +533 -0
  42. greenproof/rules/tests_model.py +84 -0
  43. greenproof/rules/tests_python.py +1201 -0
  44. greenproof/runner.py +114 -0
  45. greenproof/schema/report-v1.json +101 -0
  46. greenproof/smoke.py +188 -0
  47. greenproof-0.1.0.dist-info/METADATA +409 -0
  48. greenproof-0.1.0.dist-info/RECORD +52 -0
  49. greenproof-0.1.0.dist-info/WHEEL +4 -0
  50. greenproof-0.1.0.dist-info/entry_points.txt +2 -0
  51. greenproof-0.1.0.dist-info/licenses/LICENSE +202 -0
  52. greenproof-0.1.0.dist-info/licenses/NOTICE +4 -0
greenproof/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """greenproof: a definition-of-done gate for code written by LLM coding agents."""
2
+
3
+ from greenproof._version import __version__
4
+
5
+ __all__ = ["__version__"]
greenproof/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The package version: the single place it is written. pyproject.toml reads it at build time."""
2
+
3
+ __version__ = "0.1.0"
greenproof/accept.py ADDED
@@ -0,0 +1,142 @@
1
+ """Local acceptances (SPEC 6.6): `greenproof accept`, `--list`, `--revoke`.
2
+
3
+ Advisory only: the file is writable by anything running as the user, an agent included.
4
+ Local and hook modes honour it; ci mode ignores it (CI reads PR comments instead, see
5
+ `github.py`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import tempfile
14
+ from collections.abc import Iterable
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from greenproof import gitops
21
+ from greenproof.errors import UsageError
22
+ from greenproof.findings import FINGERPRINT_LENGTH
23
+
24
+ ACCEPTED_FILE = "accepted.json"
25
+ _FINGERPRINT = re.compile(rf"[0-9a-f]{{{FINGERPRINT_LENGTH}}}")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Acceptance:
30
+ by: str
31
+ at: str
32
+ source: str # "local" or "github"
33
+ reason: str | None = None
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {"by": self.by, "at": self.at, "source": self.source, "reason": self.reason}
37
+
38
+
39
+ def is_fingerprint(text: str) -> bool:
40
+ return _FINGERPRINT.fullmatch(text) is not None
41
+
42
+
43
+ def accepted_path(repo: Path) -> Path:
44
+ return gitops.greenproof_dir(repo) / ACCEPTED_FILE
45
+
46
+
47
+ def load_local(repo: Path) -> dict[str, Acceptance]:
48
+ """Fingerprint to acceptance. A missing or malformed file counts as empty."""
49
+ try:
50
+ data = json.loads(accepted_path(repo).read_text())
51
+ table = data["accepted"]
52
+ except (OSError, ValueError, KeyError, TypeError):
53
+ return {}
54
+ if not isinstance(table, dict):
55
+ return {}
56
+ out: dict[str, Acceptance] = {}
57
+ for fp, entry in table.items():
58
+ if not (isinstance(fp, str) and is_fingerprint(fp) and isinstance(entry, dict)):
59
+ continue
60
+ by, at, reason = entry.get("by"), entry.get("at"), entry.get("reason")
61
+ if (
62
+ isinstance(by, str)
63
+ and isinstance(at, str)
64
+ and (reason is None or isinstance(reason, str))
65
+ ):
66
+ out[fp] = Acceptance(by, at, "local", reason)
67
+ return out
68
+
69
+
70
+ def _save(repo: Path, table: dict[str, Acceptance]) -> None:
71
+ path = accepted_path(repo)
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ payload = {
74
+ "accepted": {
75
+ fp: {"by": a.by, "at": a.at, "reason": a.reason} for fp, a in sorted(table.items())
76
+ }
77
+ }
78
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix="accepted-", suffix=".tmp")
79
+ try:
80
+ with os.fdopen(fd, "w") as handle:
81
+ json.dump(payload, handle, indent=2)
82
+ handle.write("\n")
83
+ os.replace(tmp, path)
84
+ except BaseException:
85
+ Path(tmp).unlink(missing_ok=True)
86
+ raise
87
+
88
+
89
+ def identity(repo: Path) -> str:
90
+ """`Name <email>` from git config; whichever part is missing is left out."""
91
+
92
+ def config(key: str) -> str:
93
+ proc = gitops.git(repo, "config", key, check=False)
94
+ return proc.stdout.decode(errors="replace").strip() if proc.returncode == 0 else ""
95
+
96
+ name, email = config("user.name"), config("user.email")
97
+ if name and email:
98
+ return f"{name} <{email}>"
99
+ return name or email or "unknown"
100
+
101
+
102
+ def _check_fingerprints(fingerprints: Iterable[str]) -> list[str]:
103
+ out = list(fingerprints)
104
+ for fp in out:
105
+ if not is_fingerprint(fp):
106
+ raise UsageError(
107
+ f"{fp!r} is not a fingerprint: expected {FINGERPRINT_LENGTH} lowercase hex digits"
108
+ " (shown as [fp ...] in the report)"
109
+ )
110
+ return out
111
+
112
+
113
+ def accept(repo: Path, fingerprints: Iterable[str], reason: str | None) -> list[str]:
114
+ fps = _check_fingerprints(fingerprints)
115
+ if not fps:
116
+ raise UsageError("accept: give at least one fingerprint (or --list, or --revoke FP)")
117
+ table = load_local(repo)
118
+ now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
119
+ who = identity(repo)
120
+ for fp in fps:
121
+ table[fp] = Acceptance(who, now, "local", reason or None)
122
+ _save(repo, table)
123
+ return fps
124
+
125
+
126
+ def revoke(repo: Path, fingerprint: str) -> None:
127
+ (fp,) = _check_fingerprints([fingerprint])
128
+ table = load_local(repo)
129
+ if fp not in table:
130
+ raise UsageError(f"{fp} is not accepted")
131
+ del table[fp]
132
+ _save(repo, table)
133
+
134
+
135
+ def render_list(table: dict[str, Acceptance]) -> str:
136
+ if not table:
137
+ return "greenproof: no accepted findings\n"
138
+ lines = []
139
+ for fp, a in sorted(table.items()):
140
+ reason = f" {a.reason}" if a.reason else ""
141
+ lines.append(f"{fp} {a.at} {a.by}{reason}")
142
+ return "\n".join(lines) + "\n"
greenproof/cache.py ADDED
@@ -0,0 +1,85 @@
1
+ """Remember passes so an unchanged candidate is not re-verified (SPEC 3.1 step 7).
2
+
3
+ Stored in `<git-common-dir>/greenproof/cache.json`, last 50 entries, passes only. A
4
+ failure is never cached, and a corrupt file is treated as empty (a miss just re-runs).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import tempfile
13
+ import time
14
+ from collections.abc import Mapping, Sequence
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ MAX_ENTRIES = 50
19
+
20
+
21
+ def cache_key(
22
+ tree: str,
23
+ base_sha: str,
24
+ policy_sha256: str,
25
+ version: str,
26
+ mode: str,
27
+ checks: Sequence[str],
28
+ flags: Sequence[str] = (),
29
+ ) -> str:
30
+ """sha256 over everything that determines the verdict.
31
+
32
+ `flags` carries options that change it beyond the spec's list, e.g. allow-policy-change:
33
+ without it a pass obtained with the flag would be replayed for a run without it.
34
+ """
35
+ material = {
36
+ "tree": tree,
37
+ "base": base_sha,
38
+ "policy": policy_sha256,
39
+ "version": version,
40
+ "mode": mode,
41
+ "checks": list(checks),
42
+ "flags": sorted(flags),
43
+ }
44
+ blob = json.dumps(material, sort_keys=True, separators=(",", ":"))
45
+ return hashlib.sha256(blob.encode()).hexdigest()
46
+
47
+
48
+ class PassCache:
49
+ def __init__(self, directory: Path) -> None:
50
+ self.path = directory / "cache.json"
51
+
52
+ def _load(self) -> list[dict[str, Any]]:
53
+ try:
54
+ data = json.loads(self.path.read_text())
55
+ entries = data["entries"]
56
+ except (OSError, ValueError, KeyError, TypeError):
57
+ return []
58
+ if not isinstance(entries, list):
59
+ return []
60
+ return [e for e in entries if isinstance(e, dict) and isinstance(e.get("key"), str)]
61
+
62
+ def get_pass(self, key: str) -> list[dict[str, Any]] | None:
63
+ """The check statuses of the original passing run, or None on a miss."""
64
+ for entry in self._load():
65
+ checks = entry.get("checks")
66
+ if entry["key"] == key and isinstance(checks, list):
67
+ return [c for c in checks if isinstance(c, dict)]
68
+ return None
69
+
70
+ def has_pass(self, key: str) -> bool:
71
+ return self.get_pass(key) is not None
72
+
73
+ def record_pass(self, key: str, checks: Sequence[Mapping[str, Any]]) -> None:
74
+ entries = [e for e in self._load() if e["key"] != key]
75
+ entries.append({"key": key, "at": int(time.time()), "checks": [dict(c) for c in checks]})
76
+ entries = entries[-MAX_ENTRIES:]
77
+ self.path.parent.mkdir(parents=True, exist_ok=True)
78
+ fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix="cache-", suffix=".tmp")
79
+ try:
80
+ with os.fdopen(fd, "w") as handle:
81
+ json.dump({"entries": entries}, handle)
82
+ os.replace(tmp, self.path)
83
+ except BaseException:
84
+ Path(tmp).unlink(missing_ok=True)
85
+ raise
@@ -0,0 +1,138 @@
1
+ """The change set: files that differ between base and candidate, with both-side contents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ import re
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from greenproof import gitops
12
+
13
+ _JS_TEST = re.compile(r".*\.(test)\.(js|jsx|ts|tsx|mjs|cjs)$")
14
+ _JS_SPEC = re.compile(r".*\.spec\.[^/]+$")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ChangedFile:
19
+ path: str
20
+ status: str # A, M, D, R
21
+ old_path: str | None
22
+ old_content: bytes | None
23
+ new_content: bytes | None
24
+ is_test: bool
25
+ was_test: bool # whether the old path (or the path itself) was a test file at base
26
+
27
+ @property
28
+ def old_text(self) -> str | None:
29
+ return None if self.old_content is None else self.old_content.decode(errors="replace")
30
+
31
+ @property
32
+ def new_text(self) -> str | None:
33
+ return None if self.new_content is None else self.new_content.decode(errors="replace")
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ChangeSet:
38
+ files: list[ChangedFile] = field(default_factory=list)
39
+ skipped_binary: list[str] = field(default_factory=list)
40
+ # Reads any file as it was at the base revision (None when absent), changed or not.
41
+ read_base: Callable[[str], bytes | None] | None = None
42
+
43
+ def base_text(self, path: str) -> str | None:
44
+ """The file's text at the base revision, or None when it did not exist there."""
45
+ if self.read_base is not None:
46
+ blob = self.read_base(path)
47
+ return None if blob is None else blob.decode(errors="replace")
48
+ for f in self.files:
49
+ if (f.old_path or f.path) == path:
50
+ return f.old_text
51
+ return None
52
+
53
+ def tests(self) -> list[ChangedFile]:
54
+ return [f for f in self.files if f.is_test or f.was_test]
55
+
56
+ def non_tests(self) -> list[ChangedFile]:
57
+ return [f for f in self.files if not f.is_test and not f.was_test]
58
+
59
+
60
+ def _glob_to_regex(pattern: str) -> re.Pattern[str]:
61
+ """`*` and `?` stay inside one path segment; `**` crosses segments; `**/` may be empty."""
62
+ out: list[str] = []
63
+ i = 0
64
+ while i < len(pattern):
65
+ char = pattern[i]
66
+ if char == "*":
67
+ if pattern[i : i + 2] == "**":
68
+ if pattern[i : i + 3] == "**/":
69
+ out.append("(?:.*/)?")
70
+ i += 3
71
+ continue
72
+ out.append(".*")
73
+ i += 2
74
+ continue
75
+ out.append("[^/]*")
76
+ elif char == "?":
77
+ out.append("[^/]")
78
+ else:
79
+ out.append(re.escape(char))
80
+ i += 1
81
+ return re.compile("".join(out) + r"\Z")
82
+
83
+
84
+ def matches_glob(path: str, pattern: str) -> bool:
85
+ """A pattern without a slash matches the file name at any depth; otherwise the full path."""
86
+ target = path if "/" in pattern else posixpath.basename(path)
87
+ return _glob_to_regex(pattern.lstrip("/")).match(target) is not None
88
+
89
+
90
+ def is_test_path(path: str, patterns: Sequence[str] = ()) -> bool:
91
+ """Whether `path` is a test file: the policy's `tests.paths`, else the language defaults."""
92
+ if patterns:
93
+ return any(matches_glob(path, p) for p in patterns)
94
+ name = posixpath.basename(path)
95
+ dirs = posixpath.dirname(path).split("/")
96
+ in_test_dir = "tests" in dirs or "test" in dirs
97
+ if name.endswith(".py"):
98
+ return (
99
+ name.startswith("test_")
100
+ or name.endswith("_test.py")
101
+ or name == "conftest.py"
102
+ or in_test_dir
103
+ )
104
+ if name.endswith(".go"):
105
+ return name.endswith("_test.go")
106
+ if name.endswith(".rs"):
107
+ return "tests" in dirs
108
+ return bool(_JS_TEST.match(name) or _JS_SPEC.match(name) or "__tests__" in dirs)
109
+
110
+
111
+ def _is_binary(*blobs: bytes | None) -> bool:
112
+ return any(b is not None and b"\0" in b[:8000] for b in blobs)
113
+
114
+
115
+ def build_changeset(
116
+ repo: Path, base_sha: str, tree: str, test_patterns: Sequence[str] = ()
117
+ ) -> ChangeSet:
118
+ files: list[ChangedFile] = []
119
+ skipped: list[str] = []
120
+ for entry in gitops.diff_name_status(repo, base_sha, tree):
121
+ old_path = entry.old_path or entry.path
122
+ old = None if entry.status == "A" else gitops.read_blob(repo, base_sha, old_path)
123
+ new = None if entry.status == "D" else gitops.read_blob(repo, tree, entry.path)
124
+ if _is_binary(old, new):
125
+ skipped.append(entry.path)
126
+ continue
127
+ files.append(
128
+ ChangedFile(
129
+ path=entry.path,
130
+ status=entry.status,
131
+ old_path=entry.old_path,
132
+ old_content=old,
133
+ new_content=new,
134
+ is_test=is_test_path(entry.path, test_patterns),
135
+ was_test=entry.status != "A" and is_test_path(old_path, test_patterns),
136
+ )
137
+ )
138
+ return ChangeSet(files, skipped, lambda p: gitops.read_blob(repo, base_sha, p))
greenproof/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ """Command-line entry point. Maps every outcome to an exit code and fails closed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import NoReturn
12
+
13
+ from greenproof import __version__, accept, gitops, hooks, pipeline
14
+ from greenproof.errors import EXIT_INTERNAL, EXIT_USAGE, GreenproofError, UsageError
15
+ from greenproof.registry import RULES, rule_ids
16
+ from greenproof.report import (
17
+ Report,
18
+ error_report,
19
+ render_annotations,
20
+ render_markdown,
21
+ render_text,
22
+ to_json_dict,
23
+ )
24
+
25
+ HOOKS = hooks.HOOK_AGENTS
26
+
27
+
28
+ class _Parser(argparse.ArgumentParser):
29
+ """argparse exits 2 on bad usage; greenproof reserves 2 for a failed command."""
30
+
31
+ def error(self, message: str) -> NoReturn:
32
+ self.print_usage(sys.stderr)
33
+ self.exit(EXIT_USAGE, f"{self.prog}: error: {message}\n")
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ parser = _Parser(prog="greenproof", description="Is the green real?")
38
+ parser.add_argument("--version", action="version", version=f"greenproof {__version__}")
39
+ sub = parser.add_subparsers(dest="command", required=True, parser_class=_Parser)
40
+
41
+ check = sub.add_parser("check", help="verify the current change")
42
+ check.add_argument("--base", metavar="REF")
43
+ check.add_argument("--mode", choices=pipeline.MODES, default=None)
44
+ check.add_argument("--hook", choices=HOOKS)
45
+ check.add_argument(
46
+ "--checks",
47
+ metavar="LIST",
48
+ help=f"comma-separated subset of {','.join(pipeline.IMPLEMENTED_CHECKS)}",
49
+ )
50
+ check.add_argument("--json", metavar="PATH", dest="json_path")
51
+ check.add_argument("--markdown", metavar="PATH", dest="markdown_path")
52
+ check.add_argument("--github-annotations", action="store_true")
53
+ check.add_argument("--github-accept", action="store_true")
54
+ check.add_argument("--allow-policy-change", action="store_true")
55
+ check.add_argument("--no-cache", action="store_true")
56
+ check.add_argument("--isolate", action="store_true")
57
+
58
+ start = sub.add_parser("session-start", help="record the revision the session started from")
59
+ start.add_argument("--hook", choices=HOOKS)
60
+
61
+ acc = sub.add_parser("accept", help="accept findings by fingerprint (advisory, local)")
62
+ acc.add_argument("fingerprints", nargs="*", metavar="FINGERPRINT")
63
+ acc.add_argument("--reason", metavar="TEXT")
64
+ acc.add_argument("--list", action="store_true", dest="list_accepted")
65
+ acc.add_argument("--revoke", metavar="FINGERPRINT")
66
+
67
+ explain = sub.add_parser("explain", help="describe a rule")
68
+ explain.add_argument("rule_id", metavar="RULE_ID")
69
+
70
+ init = sub.add_parser("init", help="set up greenproof in a repository")
71
+ init.add_argument(
72
+ "--yes", action="store_true", help="overwrite an existing policy and workflow"
73
+ )
74
+ init.add_argument(
75
+ "--agents",
76
+ default="auto",
77
+ metavar="auto|none|LIST",
78
+ help="agents to install hooks for: auto, none or a list of claude,cursor,codex,gemini",
79
+ )
80
+ init.add_argument("--ci", choices=("github", "none"), default="none")
81
+ init.add_argument("--no-verify", action="store_true", help="do not run the proposed commands")
82
+ init.add_argument("--hook-command", metavar="CMD", help="how the hooks invoke greenproof")
83
+ return parser
84
+
85
+
86
+ def _write(path: str, text: str) -> None:
87
+ Path(path).write_text(text)
88
+
89
+
90
+ def _write_files(report: Report, args: argparse.Namespace) -> None:
91
+ if getattr(args, "json_path", None):
92
+ _write(args.json_path, json.dumps(to_json_dict(report), indent=2) + "\n")
93
+ if getattr(args, "markdown_path", None):
94
+ with open(args.markdown_path, "a") as handle: # GITHUB_STEP_SUMMARY is appended to
95
+ handle.write(render_markdown(report))
96
+
97
+
98
+ def _emit(report: Report, args: argparse.Namespace) -> None:
99
+ sys.stdout.write(render_text(report))
100
+ if getattr(args, "github_annotations", False):
101
+ sys.stdout.write(render_annotations(report))
102
+ _write_files(report, args)
103
+
104
+
105
+ def _resolve_mode(args: argparse.Namespace) -> str:
106
+ """`--hook` implies `--mode hook`; an explicit other mode with it is a usage error."""
107
+ if args.hook:
108
+ if args.mode not in (None, "hook"):
109
+ raise UsageError(
110
+ f"--hook implies --mode hook; it cannot be combined with --mode {args.mode}"
111
+ )
112
+ return "hook"
113
+ return args.mode or "local"
114
+
115
+
116
+ def _cmd_check(args: argparse.Namespace) -> int:
117
+ mode = _resolve_mode(args)
118
+ checks = None
119
+ if args.checks is not None:
120
+ checks = [c.strip() for c in args.checks.split(",") if c.strip()]
121
+ if not checks:
122
+ raise UsageError("--checks: expected a comma-separated list")
123
+ opts = pipeline.CheckOptions(
124
+ cwd=Path.cwd(),
125
+ base=args.base,
126
+ mode=mode,
127
+ hook=args.hook,
128
+ checks=checks,
129
+ allow_policy_change=args.allow_policy_change,
130
+ no_cache=args.no_cache,
131
+ isolate=args.isolate,
132
+ github_accept=args.github_accept,
133
+ env=dict(os.environ),
134
+ )
135
+ if args.hook:
136
+ return _run_hook(args, opts)
137
+ report = pipeline.run_check(opts)
138
+ _emit(report, args)
139
+ return report.exit_code
140
+
141
+
142
+ def _run_hook(args: argparse.Namespace, opts: pipeline.CheckOptions) -> int:
143
+ payload = hooks.parse_payload(args.hook, hooks.read_payload(sys.stdin))
144
+ result = hooks.run_hook(args.hook, payload, opts)
145
+ # In hook mode stdout carries only the protocol output; files are still written.
146
+ if result.report is not None:
147
+ try:
148
+ _write_files(result.report, args)
149
+ except OSError as exc:
150
+ sys.stderr.write(f"greenproof: could not write the report: {exc}\n")
151
+ sys.stdout.write(result.stdout)
152
+ sys.stderr.write(result.stderr)
153
+ return result.exit_code
154
+
155
+
156
+ def _cmd_session_start(args: argparse.Namespace) -> int:
157
+ if args.hook:
158
+ # Session start never blocks: any error is exit 0, nothing on stdout, one line on stderr.
159
+ try:
160
+ payload = hooks.parse_payload(args.hook, hooks.read_payload(sys.stdin))
161
+ output = hooks.run_session_start(args.hook, payload, Path.cwd())
162
+ except Exception as exc:
163
+ sys.stderr.write(f"greenproof: session-start: {exc}\n")
164
+ return 0
165
+ sys.stdout.write(output)
166
+ return 0
167
+ repo = gitops.find_repo(Path.cwd())
168
+ gitops.require_commits(repo)
169
+ marker = gitops.write_session_marker(repo)
170
+ sys.stdout.write(f"greenproof: session base recorded in {marker}\n")
171
+ return 0
172
+
173
+
174
+ def _cmd_accept(args: argparse.Namespace) -> int:
175
+ repo = gitops.find_repo(Path.cwd())
176
+ if args.list_accepted or args.revoke:
177
+ if args.fingerprints or args.reason or (args.list_accepted and args.revoke):
178
+ raise UsageError("accept: --list and --revoke take no other arguments")
179
+ if args.revoke:
180
+ accept.revoke(repo, args.revoke)
181
+ sys.stdout.write(f"greenproof: revoked {args.revoke}\n")
182
+ else:
183
+ sys.stdout.write(accept.render_list(accept.load_local(repo)))
184
+ return 0
185
+ done = accept.accept(repo, args.fingerprints, args.reason)
186
+ who = accept.identity(repo)
187
+ for fp in done:
188
+ sys.stdout.write(f"greenproof: accepted {fp} as {who}\n")
189
+ return 0
190
+
191
+
192
+ def _cmd_explain(args: argparse.Namespace) -> int:
193
+ info = RULES.get(args.rule_id)
194
+ if info is None:
195
+ raise UsageError(f"unknown rule id {args.rule_id!r}; known: {', '.join(rule_ids())}")
196
+ sys.stdout.write(
197
+ f"{info.id} (default severity: {info.default_severity})\n\n{info.description}\n"
198
+ )
199
+ return 0
200
+
201
+
202
+ def _cmd_init(args: argparse.Namespace) -> int:
203
+ from greenproof.init.command import InitOptions, run_init
204
+
205
+ summary = run_init(
206
+ InitOptions(
207
+ cwd=Path.cwd(),
208
+ yes=args.yes,
209
+ agents=args.agents,
210
+ ci=args.ci,
211
+ no_verify=args.no_verify,
212
+ hook_command=args.hook_command,
213
+ )
214
+ )
215
+ sys.stdout.write(summary)
216
+ return 0
217
+
218
+
219
+ def _fail(args: argparse.Namespace, message: str, code: int) -> int:
220
+ """Report an error on stderr (and as a JSON error report when --json was given)."""
221
+ sys.stderr.write(f"greenproof: error: {message}\n")
222
+ json_path = getattr(args, "json_path", None)
223
+ if json_path:
224
+ mode = getattr(args, "mode", None) or ("hook" if getattr(args, "hook", None) else "local")
225
+ try:
226
+ report = error_report(__version__, mode, message, code)
227
+ _write(json_path, json.dumps(to_json_dict(report), indent=2) + "\n")
228
+ except OSError:
229
+ pass
230
+ return code
231
+
232
+
233
+ def main(argv: Sequence[str] | None = None) -> int:
234
+ parser = build_parser()
235
+ try:
236
+ args = parser.parse_args(argv)
237
+ except SystemExit as exc:
238
+ return exc.code if isinstance(exc.code, int) else 0
239
+ handlers = {
240
+ "check": _cmd_check,
241
+ "session-start": _cmd_session_start,
242
+ "accept": _cmd_accept,
243
+ "explain": _cmd_explain,
244
+ "init": _cmd_init,
245
+ }
246
+ try:
247
+ return handlers[args.command](args)
248
+ except GreenproofError as exc:
249
+ return _fail(args, str(exc), exc.exit_code)
250
+ except Exception as exc: # fail closed: never exit 0 on an unexpected error
251
+ return _fail(args, f"internal error: {type(exc).__name__}: {exc}", EXIT_INTERNAL)
252
+
253
+
254
+ if __name__ == "__main__":
255
+ sys.exit(main())
greenproof/errors.py ADDED
@@ -0,0 +1,26 @@
1
+ """Errors that map to exit codes. Anything else that escapes is an internal error (exit 5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ EXIT_PASS = 0
6
+ EXIT_INTEGRITY = 1
7
+ EXIT_COMMAND = 2
8
+ EXIT_SMOKE = 3
9
+ EXIT_USAGE = 4
10
+ EXIT_INTERNAL = 5
11
+
12
+
13
+ class GreenproofError(Exception):
14
+ """Base class for errors greenproof raises on purpose."""
15
+
16
+ exit_code = EXIT_INTERNAL
17
+
18
+
19
+ class UsageError(GreenproofError):
20
+ """Usage or configuration error: not a repo, base unresolvable, bad policy (exit 4)."""
21
+
22
+ exit_code = EXIT_USAGE
23
+
24
+
25
+ class PolicyError(UsageError):
26
+ """The policy file is invalid; the message names the offending key."""