commitguardian 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 (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,137 @@
1
+ """``commitguard init``: set a repository up for enforcement.
2
+
3
+ Writes ``.commitguard.yaml``, and can install the Git hooks and write a GitHub
4
+ workflow. Existing files are never overwritten.
5
+
6
+ Interactive by default when the terminal can answer (a TTY), and fully
7
+ non-interactive otherwise, so scripts and CI behave the same as ``--non-interactive``:
8
+ nothing is prompted, and only what the options ask for is done.
9
+ """
10
+
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import typer
16
+
17
+ from commitguard.cli.output import fail, handled_errors, info
18
+ from commitguard.config.defaults import DEFAULT_CONFIG_FILENAME, DEFAULT_CONFIG_TEMPLATE
19
+ from commitguard.config.loader import find_config
20
+ from commitguard.git.repository import Repository
21
+ from commitguard.github.workflow import CHECK_NAME, WORKFLOW_FILE, render_workflow
22
+ from commitguard.utils.filesystem import atomic_write_text
23
+
24
+
25
+ def _write_new(path: Path, content: str) -> None:
26
+ try:
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ atomic_write_text(path, content)
29
+ except FileExistsError:
30
+ fail(f"{path} already exists; not overwriting it")
31
+ except OSError as exc:
32
+ fail(f"could not write {path}: {exc.strerror}")
33
+
34
+
35
+ def _ask(question: str, *, default: bool) -> bool:
36
+ return bool(typer.confirm(question, default=default))
37
+
38
+
39
+ def init_command(
40
+ github: Annotated[
41
+ bool,
42
+ typer.Option("--github", help=f"Also create {WORKFLOW_FILE.as_posix()}."),
43
+ ] = False,
44
+ install: Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "--install-hooks", help="Also install the Git hooks (as `commitguard install`)."
48
+ ),
49
+ ] = False,
50
+ non_interactive: Annotated[
51
+ bool,
52
+ typer.Option(
53
+ "--non-interactive",
54
+ help="Never prompt; do exactly what the options say (the default when not a terminal).",
55
+ ),
56
+ ] = False,
57
+ action_repository: Annotated[
58
+ str | None,
59
+ typer.Option(
60
+ "--action-repository",
61
+ help="Repository hosting the CommitGuard Action (OWNER/REPO), used with --github.",
62
+ ),
63
+ ] = None,
64
+ action_ref: Annotated[
65
+ str | None,
66
+ typer.Option(
67
+ "--action-ref",
68
+ help="Full commit SHA of the CommitGuard Action to pin, used with --github.",
69
+ ),
70
+ ] = None,
71
+ ) -> None:
72
+ """Create .commitguard.yaml, and optionally install hooks and a GitHub workflow."""
73
+ interactive = not non_interactive and sys.stdin.isatty() and sys.stdout.isatty()
74
+ with handled_errors():
75
+ repository = Repository.discover()
76
+ if interactive:
77
+ info(f"Setting up CommitGuard in {repository.root}")
78
+ if not github and not (action_repository or action_ref):
79
+ github = _ask(
80
+ "Add a GitHub Actions check (recommended: it cannot be bypassed)?",
81
+ default=False,
82
+ )
83
+ if github:
84
+ action_repository = action_repository or typer.prompt(
85
+ "Repository hosting the CommitGuard Action (OWNER/REPO)"
86
+ )
87
+ action_ref = action_ref or typer.prompt(
88
+ "Commit SHA of the Action to pin (40 characters)"
89
+ )
90
+ if not install:
91
+ install = _ask("Install the Git hooks in this repository now?", default=True)
92
+ workflow_path = repository.root / WORKFLOW_FILE
93
+ workflow_text = None
94
+ if github:
95
+ if not action_repository or not action_ref:
96
+ fail(
97
+ "--github requires --action-repository OWNER/REPO and --action-ref "
98
+ "<40-character commit SHA> (the Action is pinned to an exact commit)"
99
+ )
100
+ if workflow_path.exists() or workflow_path.is_symlink():
101
+ fail(f"{workflow_path} already exists; not overwriting it")
102
+ workflow_text = render_workflow(action_repository, action_ref)
103
+ elif action_repository or action_ref:
104
+ fail("--action-repository and --action-ref are only used with --github")
105
+
106
+ existing = find_config(repository.root)
107
+ if existing is not None and not github:
108
+ fail(f"configuration already exists: {existing}")
109
+
110
+ created = []
111
+ if existing is None:
112
+ target = repository.root / DEFAULT_CONFIG_FILENAME
113
+ _write_new(target, DEFAULT_CONFIG_TEMPLATE)
114
+ created.append(target)
115
+ if workflow_text is not None:
116
+ _write_new(workflow_path, workflow_text)
117
+ created.append(workflow_path)
118
+
119
+ for path in created:
120
+ info(f"Created {path}")
121
+ if install:
122
+ from commitguard.cli.commands.install import install_command
123
+
124
+ info("")
125
+ install_command()
126
+ info("")
127
+ if existing is not None:
128
+ info(f"Kept existing configuration: {existing}")
129
+ info("Next: review and commit the files.")
130
+ if github:
131
+ info(
132
+ f'Then require the "{CHECK_NAME}" status check on protected branches '
133
+ "(see `commitguard github setup`); the workflow alone does not block merges."
134
+ )
135
+ else:
136
+ if not install:
137
+ info("Run `commitguard install` to enforce policies in local Git hooks.")
@@ -0,0 +1,152 @@
1
+ """``commitguard install`` / ``commitguard uninstall``: manage Git hooks.
2
+
3
+ Existing hooks are never overwritten: they are preserved as
4
+ ``<hook>.pre-commitguard`` and run after CommitGuard. ``uninstall`` removes
5
+ only CommitGuard's managed block and restores preserved hooks.
6
+ """
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from commitguard.cli.output import handled_errors, info, supports_unicode
13
+ from commitguard.config.enforcement import build_enforcement
14
+ from commitguard.config.loader import load_effective_config
15
+ from commitguard.exceptions.base import CommitGuardError
16
+ from commitguard.git.hooks import (
17
+ HookType,
18
+ InstallAction,
19
+ InstallResult,
20
+ UninstallAction,
21
+ UninstallResult,
22
+ default_python,
23
+ install_global,
24
+ install_hooks,
25
+ uninstall_global,
26
+ uninstall_hooks,
27
+ )
28
+ from commitguard.git.repository import Repository
29
+ from commitguard.security.sanitization import sanitize_for_terminal
30
+
31
+ HookOption = Annotated[
32
+ list[HookType] | None,
33
+ typer.Option("--hook", help="Limit to this hook (repeatable). Default: all three."),
34
+ ]
35
+ GlobalOption = Annotated[
36
+ bool,
37
+ typer.Option(
38
+ "--global",
39
+ help="Use a Git template directory so NEW repositories (git init/clone) get the hooks.",
40
+ ),
41
+ ]
42
+ SharedOption = Annotated[
43
+ bool,
44
+ typer.Option(
45
+ "--allow-shared-hooks-path",
46
+ help="Allow a core.hooksPath outside this repository's Git directory.",
47
+ ),
48
+ ]
49
+
50
+ BYPASS_NOTE = (
51
+ "Note: local hooks can be bypassed (git commit/push --no-verify) by anyone who controls "
52
+ "this clone. Authoritative protection needs the GitHub check required by branch "
53
+ "protection (see: commitguard github setup)."
54
+ )
55
+
56
+
57
+ def _mark() -> str:
58
+ return "✓" if supports_unicode() else "OK"
59
+
60
+
61
+ def _describe_install(result: InstallResult) -> str:
62
+ name = result.hook.value
63
+ text = {
64
+ InstallAction.INSTALLED: f"Installed {name} hook",
65
+ InstallAction.CHAINED: f"Installed {name} hook",
66
+ InstallAction.UPDATED: f"Updated {name} hook",
67
+ InstallAction.UNCHANGED: f"{name} hook already installed and up to date",
68
+ }[result.action]
69
+ if result.chained is not None:
70
+ text += f" (existing hook preserved as {result.chained.name}; it runs after CommitGuard)"
71
+ return f"{_mark()} {text}"
72
+
73
+
74
+ def _describe_uninstall(result: UninstallResult) -> str:
75
+ name = result.hook.value
76
+ text = {
77
+ UninstallAction.REMOVED: f"{_mark()} Removed CommitGuard {name} hook",
78
+ UninstallAction.RESTORED: f"{_mark()} Removed CommitGuard {name} hook and restored the "
79
+ "hook that existed before installation",
80
+ UninstallAction.BLOCK_REMOVED: f"{_mark()} Removed CommitGuard block from {name} hook "
81
+ "(other content kept)",
82
+ UninstallAction.NOT_INSTALLED: f"- {name}: CommitGuard hook not installed",
83
+ UninstallAction.FOREIGN: f"- {name}: not a CommitGuard hook; left untouched",
84
+ }[result.action]
85
+ return text + (f" ({result.note})" if result.note else "")
86
+
87
+
88
+ def install_command(
89
+ hooks: HookOption = None,
90
+ global_: GlobalOption = False,
91
+ allow_shared_hooks_path: SharedOption = False,
92
+ ) -> None:
93
+ """Install CommitGuard Git hooks (pre-commit, commit-msg, pre-push)."""
94
+ selected = tuple(hooks) if hooks else None
95
+ with handled_errors():
96
+ info("CommitGuard")
97
+ if global_:
98
+ template, results, configured = install_global(selected)
99
+ info(f"Template directory: {sanitize_for_terminal(str(template))}")
100
+ for result in results:
101
+ info(_describe_install(result))
102
+ if configured:
103
+ info(f"{_mark()} Set global git config init.templateDir")
104
+ info("New repositories created with git init / git clone will include the hooks.")
105
+ info("Existing repositories are unchanged: run `commitguard install` inside them.")
106
+ info(BYPASS_NOTE)
107
+ return
108
+
109
+ repository = Repository.discover()
110
+ results = install_hooks(
111
+ repository, selected, allow_shared_hooks_path=allow_shared_hooks_path
112
+ )
113
+ info(f"Hooks directory: {sanitize_for_terminal(str(results[0].path.parent))}")
114
+ for result in results:
115
+ info(_describe_install(result))
116
+ info(f"Hooks run: {sanitize_for_terminal(default_python())} -P -m commitguard hook <name>")
117
+ info(" (falls back to `commitguard` on PATH if that interpreter is removed)")
118
+ try:
119
+ enforcement = build_enforcement(*load_effective_config(repository.root).configs)
120
+ except CommitGuardError as exc:
121
+ info(f"! Configuration is invalid; hooks will block until it is fixed: {exc}")
122
+ else:
123
+ for name in ("pre_commit", "commit_msg", "pre_push"):
124
+ if not enforcement.enabled(name):
125
+ info(f"! {name.replace('_', '-')} enforcement is disabled in configuration")
126
+ info(BYPASS_NOTE)
127
+
128
+
129
+ def uninstall_command(
130
+ hooks: HookOption = None,
131
+ global_: GlobalOption = False,
132
+ allow_shared_hooks_path: SharedOption = False,
133
+ ) -> None:
134
+ """Remove CommitGuard hooks, preserving and restoring any other hooks."""
135
+ selected = tuple(hooks) if hooks else None
136
+ with handled_errors():
137
+ info("CommitGuard")
138
+ if global_:
139
+ _, uninstall_results, unset = uninstall_global(selected)
140
+ for result in uninstall_results:
141
+ info(_describe_uninstall(result))
142
+ if unset:
143
+ info(f"{_mark()} Unset global git config init.templateDir")
144
+ info("Repositories created earlier keep their copied hooks; uninstall there too.")
145
+ return
146
+ repository = Repository.discover()
147
+ uninstall_results = uninstall_hooks(
148
+ repository, selected, allow_shared_hooks_path=allow_shared_hooks_path
149
+ )
150
+ for result in uninstall_results:
151
+ info(_describe_uninstall(result))
152
+ info("Existing Git hooks were preserved.")
@@ -0,0 +1,36 @@
1
+ """``commitguard policy``: inspect policies."""
2
+
3
+ import typer
4
+
5
+ from commitguard.cli.common import ConfigOption
6
+ from commitguard.cli.output import handled_errors, info, table
7
+ from commitguard.config.loader import load_effective_config
8
+ from commitguard.exceptions.git import NotAGitRepositoryError
9
+ from commitguard.git.repository import Repository
10
+ from commitguard.policies.loader import build_policy_set
11
+
12
+ policy_app = typer.Typer(help="Inspect policies.", no_args_is_help=True)
13
+
14
+
15
+ @policy_app.command("list")
16
+ def list_command(config: ConfigOption = None) -> None:
17
+ """Show the effective policies and the configuration layers they came from."""
18
+ with handled_errors():
19
+ try:
20
+ root = Repository.discover().root
21
+ except NotAGitRepositoryError:
22
+ root = None
23
+ loaded = load_effective_config(root, explicit_path=config)
24
+ policies = build_policy_set(*loaded.configs)
25
+
26
+ info("Configuration layers (lowest precedence first):")
27
+ for source in loaded.sources:
28
+ info(f" - {source}")
29
+ if root is None:
30
+ info(" (not inside a Git repository: no repository layer)")
31
+ info("")
32
+ rows = [
33
+ [p.id, "yes" if p.enabled else "no", p.action.value, p.description]
34
+ for p in policies.values()
35
+ ]
36
+ info(table(["policy", "enabled", "action", "description"], rows))
@@ -0,0 +1,39 @@
1
+ """``commitguard report``: assemble reports from recorded results and evidence.
2
+
3
+ The report only restates recorded evidence; it never measures anything itself,
4
+ and anything without evidence is reported as "Not tested".
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from commitguard.cli.output import handled_errors, info
13
+
14
+ report_app = typer.Typer(
15
+ help="Generate reports from recorded benchmark results.", no_args_is_help=True
16
+ )
17
+
18
+
19
+ @report_app.command("security")
20
+ def security_report_command(
21
+ results: Annotated[
22
+ Path, typer.Option("--results", help="Results directory (e.g. benchmarks/results).")
23
+ ] = Path("benchmarks/results"),
24
+ evidence: Annotated[
25
+ Path | None,
26
+ typer.Option("--evidence", help="Evidence directory written by the security test suites."),
27
+ ] = None,
28
+ output: Annotated[
29
+ Path, typer.Option("--output", help="Directory to write the reports to.")
30
+ ] = Path("reports"),
31
+ ) -> None:
32
+ """Write reports/security-report.{json,md} and reports/benchmark-report.md."""
33
+ from commitguard.research.report import collect, write_reports
34
+
35
+ with handled_errors():
36
+ data = collect(results, evidence)
37
+ written = write_reports(data, output)
38
+ for path in written:
39
+ info(f"Wrote {path}")
@@ -0,0 +1,123 @@
1
+ """``commitguard reproduce``: re-run the published evidence on this machine.
2
+
3
+ Exit codes: 0 when nothing failed (steps may be SKIPPED), 1 when a step failed,
4
+ 2 on errors. A skipped step is never reported as a success.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from commitguard.cli.output import ExitCode, handled_errors, info
14
+
15
+ reproduce_app = typer.Typer(
16
+ help="Reproduce the published security, benchmark and integration evidence.",
17
+ no_args_is_help=True,
18
+ )
19
+
20
+ JsonOption = Annotated[bool, typer.Option("--json", help="Print the result document as JSON.")]
21
+ OutputOption = Annotated[
22
+ Path | None, typer.Option("--output", help="Also write the JSON document to this file.")
23
+ ]
24
+ EvidenceOption = Annotated[
25
+ Path | None,
26
+ typer.Option("--evidence-dir", help="Directory for evidence written by the test suites."),
27
+ ]
28
+ ResultsOption = Annotated[
29
+ Path | None,
30
+ typer.Option(
31
+ "--results", help="Results directory to compare against (e.g. benchmarks/results)."
32
+ ),
33
+ ]
34
+
35
+
36
+ def _emit(
37
+ areas: list[str],
38
+ as_json: bool,
39
+ output: Path | None,
40
+ evidence_dir: Path | None,
41
+ results: Path | None,
42
+ ) -> None:
43
+ from commitguard.research.environment import collect_manifest
44
+ from commitguard.research.reproduction import reproduce
45
+ from commitguard.research.results import result_document
46
+
47
+ with handled_errors():
48
+ result = reproduce(areas, evidence_dir=evidence_dir, results_dir=results)
49
+ manifest = collect_manifest(argv=["commitguard", "reproduce", *areas])
50
+ document = result_document("reproduce", manifest, result)
51
+ rendered = json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False)
52
+ if output is not None:
53
+ output.parent.mkdir(parents=True, exist_ok=True)
54
+ output.write_text(rendered + "\n", encoding="utf-8")
55
+ if as_json:
56
+ info(rendered)
57
+ else:
58
+ info("CommitGuard reproduction")
59
+ info("")
60
+ for step in result.steps:
61
+ if step.status == "NOT RUN":
62
+ continue
63
+ info(f" {step.status:<8} {step.area:<12} {step.name}")
64
+ info(f" {step.detail}")
65
+ if step.command:
66
+ info(f" $ {step.command}")
67
+ info("")
68
+ info(
69
+ f"PASS {result.passed} · FAIL {result.failed} · SKIPPED {result.skipped} "
70
+ f"· NOT RUN {result.not_run}"
71
+ )
72
+ info(
73
+ f"CommitGuard {manifest.commitguard_version} · {manifest.operating_system} "
74
+ f"{manifest.os_release} · Python {manifest.python_version} · "
75
+ f"Git {manifest.git_version or 'unknown'}"
76
+ )
77
+ if result.skipped:
78
+ info("Skipped steps were not run; they are not passes.")
79
+ if not result.ok:
80
+ raise typer.Exit(ExitCode.BLOCKED)
81
+
82
+
83
+ @reproduce_app.command("all")
84
+ def all_command(
85
+ as_json: JsonOption = False,
86
+ output: OutputOption = None,
87
+ evidence_dir: EvidenceOption = None,
88
+ results: ResultsOption = None,
89
+ ) -> None:
90
+ """Run every reproduction step (GitHub steps are skipped without credentials)."""
91
+ _emit(
92
+ ["security", "benchmark", "integration", "github"], as_json, output, evidence_dir, results
93
+ )
94
+
95
+
96
+ @reproduce_app.command("security")
97
+ def security_command(
98
+ as_json: JsonOption = False, output: OutputOption = None, evidence_dir: EvidenceOption = None
99
+ ) -> None:
100
+ """Run the security regression suite (pytest -m security)."""
101
+ _emit(["security"], as_json, output, evidence_dir, None)
102
+
103
+
104
+ @reproduce_app.command("benchmark")
105
+ def benchmark_command(
106
+ as_json: JsonOption = False, output: OutputOption = None, results: ResultsOption = None
107
+ ) -> None:
108
+ """Rebuild the detection dataset and re-measure detection against its labels."""
109
+ _emit(["benchmark"], as_json, output, None, results)
110
+
111
+
112
+ @reproduce_app.command("integration")
113
+ def integration_command(
114
+ as_json: JsonOption = False, output: OutputOption = None, evidence_dir: EvidenceOption = None
115
+ ) -> None:
116
+ """Run the integration suite (Git repositories, hooks, CI and the App service)."""
117
+ _emit(["integration"], as_json, output, evidence_dir, None)
118
+
119
+
120
+ @reproduce_app.command("github")
121
+ def github_command(as_json: JsonOption = False, output: OutputOption = None) -> None:
122
+ """Validate a real GitHub App installation (SKIPPED without credentials)."""
123
+ _emit(["github"], as_json, output, None, None)
@@ -0,0 +1,47 @@
1
+ """``commitguard scan``: analyse commits and explain every finding.
2
+
3
+ Exit codes: 0 allowed (including warnings), 1 blocked, 2 error.
4
+ """
5
+
6
+ import typer
7
+
8
+ from commitguard.cli.common import (
9
+ DEFAULT_MAX_COMMITS,
10
+ ConfigOption,
11
+ FormatOption,
12
+ MaxCommitsOption,
13
+ RevisionArgument,
14
+ )
15
+ from commitguard.cli.output import ExitCode, OutputFormat, handled_errors, info
16
+ from commitguard.cli.render import render_json, render_scan_text
17
+ from commitguard.core.context import ScanTrigger
18
+ from commitguard.core.decision import Action
19
+ from commitguard.git.repository import Repository
20
+ from commitguard.services.analysis import analyze_revisions, build_report, load_analyzer
21
+
22
+
23
+ def scan_command(
24
+ revision_range: RevisionArgument = "HEAD",
25
+ config: ConfigOption = None,
26
+ output_format: FormatOption = OutputFormat.TEXT,
27
+ max_commits: MaxCommitsOption = DEFAULT_MAX_COMMITS,
28
+ ) -> None:
29
+ """Scan commits for AI attribution and other policy violations."""
30
+ with handled_errors():
31
+ repository = Repository.discover()
32
+ analyzer, loaded = load_analyzer(repository, config_path=config)
33
+ reports = analyze_revisions(repository, analyzer, revision_range, max_commits=max_commits)
34
+ report = build_report(
35
+ reports,
36
+ repository=repository,
37
+ target=revision_range,
38
+ trigger=ScanTrigger.MANUAL,
39
+ config=loaded,
40
+ )
41
+ rendered = (
42
+ render_json(report) if output_format is OutputFormat.JSON else render_scan_text(report)
43
+ )
44
+
45
+ info(rendered)
46
+ if report.action is Action.BLOCK:
47
+ raise typer.Exit(code=int(ExitCode.BLOCKED))
@@ -0,0 +1,44 @@
1
+ """Option definitions shared by several commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from commitguard.cli.output import OutputFormat
9
+ from commitguard.services.analysis import DEFAULT_MAX_COMMITS
10
+
11
+ ConfigOption = Annotated[
12
+ Path | None,
13
+ typer.Option(
14
+ "--config",
15
+ "-c",
16
+ help="Extra configuration file, applied after global and repository configuration.",
17
+ dir_okay=False,
18
+ ),
19
+ ]
20
+
21
+ FormatOption = Annotated[
22
+ OutputFormat,
23
+ typer.Option("--format", "-f", help="Output format.", case_sensitive=False),
24
+ ]
25
+
26
+ RevisionArgument = Annotated[
27
+ str,
28
+ typer.Argument(
29
+ help="Commit (HEAD, SHA, branch) or range containing '..' (e.g. origin/main..HEAD).",
30
+ ),
31
+ ]
32
+
33
+ MaxCommitsOption = Annotated[
34
+ int,
35
+ typer.Option("--max-commits", min=1, help="Refuse ranges selecting more commits than this."),
36
+ ]
37
+
38
+ __all__ = [
39
+ "DEFAULT_MAX_COMMITS",
40
+ "ConfigOption",
41
+ "FormatOption",
42
+ "MaxCommitsOption",
43
+ "RevisionArgument",
44
+ ]
@@ -0,0 +1,89 @@
1
+ """Terminal output helpers and exit codes.
2
+
3
+ All text derived from commits, configuration files or Git error output passes
4
+ through :mod:`commitguard.security.sanitization` here or in
5
+ :mod:`commitguard.cli.render`: :func:`sanitize_block` for multi-line reason
6
+ blocks (newlines kept, continuation lines indented so none can impersonate a
7
+ CommitGuard status line) and :func:`sanitize_for_terminal` elsewhere.
8
+ """
9
+
10
+ import sys
11
+ from collections.abc import Iterator, Sequence
12
+ from contextlib import contextmanager
13
+ from enum import IntEnum, StrEnum
14
+ from typing import NoReturn
15
+
16
+ import typer
17
+
18
+ from commitguard.exceptions.base import CommitGuardError
19
+ from commitguard.security.sanitization import sanitize_block, sanitize_for_terminal
20
+
21
+
22
+ class ExitCode(IntEnum):
23
+ """Process exit codes (stable contract for hooks and CI).
24
+
25
+ 0 allowed - no findings, or only findings whose policy is allow/warn
26
+ 1 blocked - at least one finding or detector failure evaluated to BLOCK
27
+ 2 error - invalid configuration or rules, Git failure, bad arguments,
28
+ unimplemented command, or any unexpected runtime error
29
+ """
30
+
31
+ OK = 0
32
+ BLOCKED = 1
33
+ ERROR = 2
34
+
35
+
36
+ class OutputFormat(StrEnum):
37
+ TEXT = "text"
38
+ JSON = "json"
39
+
40
+
41
+ def info(message: str) -> None:
42
+ typer.echo(message)
43
+
44
+
45
+ def error(message: str) -> None:
46
+ typer.echo(
47
+ "commitguard: error: " + sanitize_block(message, max_length=4000),
48
+ err=True,
49
+ )
50
+
51
+
52
+ def fail(message: str, code: ExitCode = ExitCode.ERROR) -> NoReturn:
53
+ error(message)
54
+ raise typer.Exit(code=int(code))
55
+
56
+
57
+ def supports_unicode() -> bool:
58
+ encoding = (getattr(sys.stdout, "encoding", None) or "").lower()
59
+ return encoding.startswith("utf")
60
+
61
+
62
+ def table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
63
+ """Render a plain-text table. Cell values are sanitised."""
64
+ clean_rows = [[sanitize_for_terminal(cell, max_length=200) for cell in row] for row in rows]
65
+ widths = [len(h) for h in headers]
66
+ for row in clean_rows:
67
+ for index, cell in enumerate(row):
68
+ widths[index] = max(widths[index], len(cell))
69
+ lines = [" ".join(h.upper().ljust(widths[i]) for i, h in enumerate(headers)).rstrip()]
70
+ for row in clean_rows:
71
+ lines.append(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip())
72
+ return "\n".join(lines)
73
+
74
+
75
+ @contextmanager
76
+ def handled_errors() -> Iterator[None]:
77
+ """Map every failure to a clean message and exit code 2.
78
+
79
+ Unexpected exceptions must not surface as exit code 1 (which means
80
+ "blocked by policy") or print tracebacks that could include local data.
81
+ """
82
+ try:
83
+ yield
84
+ except typer.Exit:
85
+ raise
86
+ except CommitGuardError as exc:
87
+ fail(str(exc))
88
+ except Exception as exc: # noqa: BLE001 - last-resort mapping to the error exit code
89
+ fail(f"internal error ({type(exc).__name__}): {exc}")