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.
- greenproof/__init__.py +5 -0
- greenproof/_version.py +3 -0
- greenproof/accept.py +142 -0
- greenproof/cache.py +85 -0
- greenproof/changeset.py +138 -0
- greenproof/cli.py +255 -0
- greenproof/errors.py +26 -0
- greenproof/findings.py +42 -0
- greenproof/github.py +155 -0
- greenproof/gitops.py +330 -0
- greenproof/hooks.py +423 -0
- greenproof/init/__init__.py +1 -0
- greenproof/init/agents_md.py +73 -0
- greenproof/init/ci_github.py +84 -0
- greenproof/init/command.py +226 -0
- greenproof/init/detect.py +387 -0
- greenproof/init/hooks/__init__.py +16 -0
- greenproof/init/hooks/_common.py +93 -0
- greenproof/init/hooks/claude.py +32 -0
- greenproof/init/hooks/codex.py +36 -0
- greenproof/init/hooks/cursor.py +24 -0
- greenproof/init/hooks/gemini.py +40 -0
- greenproof/init/write_policy.py +37 -0
- greenproof/pins.py +43 -0
- greenproof/pipeline.py +313 -0
- greenproof/policy.py +257 -0
- greenproof/py.typed +0 -0
- greenproof/registry.py +155 -0
- greenproof/report.py +309 -0
- greenproof/revert.py +260 -0
- greenproof/rules/__init__.py +37 -0
- greenproof/rules/_common.py +49 -0
- greenproof/rules/ci.py +183 -0
- greenproof/rules/collect.py +101 -0
- greenproof/rules/hardcoded.py +284 -0
- greenproof/rules/parse.py +73 -0
- greenproof/rules/runner.py +664 -0
- greenproof/rules/self_mocked.py +366 -0
- greenproof/rules/suppress.py +108 -0
- greenproof/rules/tests_integrity.py +567 -0
- greenproof/rules/tests_lines.py +533 -0
- greenproof/rules/tests_model.py +84 -0
- greenproof/rules/tests_python.py +1201 -0
- greenproof/runner.py +114 -0
- greenproof/schema/report-v1.json +101 -0
- greenproof/smoke.py +188 -0
- greenproof-0.1.0.dist-info/METADATA +409 -0
- greenproof-0.1.0.dist-info/RECORD +52 -0
- greenproof-0.1.0.dist-info/WHEEL +4 -0
- greenproof-0.1.0.dist-info/entry_points.txt +2 -0
- greenproof-0.1.0.dist-info/licenses/LICENSE +202 -0
- greenproof-0.1.0.dist-info/licenses/NOTICE +4 -0
greenproof/__init__.py
ADDED
greenproof/_version.py
ADDED
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
|
greenproof/changeset.py
ADDED
|
@@ -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."""
|