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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Automation / bot identities. Bots are NOT AI agents.
|
|
2
|
+
#
|
|
3
|
+
# Only identities listed here produce bot_identity findings: there is no
|
|
4
|
+
# generic "[bot]" heuristic, because AI agent accounts use that suffix too and
|
|
5
|
+
# must be classified by ai-identities.yaml instead.
|
|
6
|
+
#
|
|
7
|
+
# Same matching semantics as ai-identities.yaml (names, emails, github_logins).
|
|
8
|
+
|
|
9
|
+
schema_version: 1
|
|
10
|
+
|
|
11
|
+
bots:
|
|
12
|
+
- id: dependabot
|
|
13
|
+
display_name: Dependabot
|
|
14
|
+
vendor: GitHub
|
|
15
|
+
names: ["dependabot", "dependabot[bot]", "dependabot-preview[bot]"]
|
|
16
|
+
github_logins: ["dependabot[bot]", "dependabot-preview[bot]"]
|
|
17
|
+
verified: true
|
|
18
|
+
reference: "49699333+dependabot[bot]@users.noreply.github.com"
|
|
19
|
+
|
|
20
|
+
- id: github_actions
|
|
21
|
+
display_name: GitHub Actions
|
|
22
|
+
vendor: GitHub
|
|
23
|
+
names: ["github-actions", "github-actions[bot]"]
|
|
24
|
+
github_logins: ["github-actions[bot]"]
|
|
25
|
+
verified: true
|
|
26
|
+
reference: "41898282+github-actions[bot]@users.noreply.github.com"
|
|
27
|
+
|
|
28
|
+
- id: renovate
|
|
29
|
+
display_name: Renovate
|
|
30
|
+
vendor: Mend
|
|
31
|
+
names: ["renovate", "renovate[bot]", "Renovate Bot"]
|
|
32
|
+
github_logins: ["renovate[bot]"]
|
|
33
|
+
emails: ["bot@renovateapp.com"]
|
|
34
|
+
verified: false
|
|
35
|
+
|
|
36
|
+
- id: pre_commit_ci
|
|
37
|
+
display_name: pre-commit.ci
|
|
38
|
+
vendor: pre-commit.ci
|
|
39
|
+
names: ["pre-commit-ci[bot]"]
|
|
40
|
+
github_logins: ["pre-commit-ci[bot]"]
|
|
41
|
+
verified: false
|
|
42
|
+
|
|
43
|
+
- id: release_bot
|
|
44
|
+
display_name: Release bot
|
|
45
|
+
names: ["release-bot", "semantic-release-bot"]
|
|
46
|
+
verified: false
|
|
47
|
+
|
|
48
|
+
- id: mergify
|
|
49
|
+
display_name: Mergify
|
|
50
|
+
vendor: Mergify
|
|
51
|
+
names: ["mergify[bot]"]
|
|
52
|
+
github_logins: ["mergify[bot]"]
|
|
53
|
+
verified: false
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Trailer and message patterns.
|
|
2
|
+
#
|
|
3
|
+
# All keys are canonical trailer keys (lowercase, hyphenated). Written keys are
|
|
4
|
+
# normalised before comparison, so "Co-Authored-By", "co_authored by" and
|
|
5
|
+
# keys containing zero-width characters all compare equal.
|
|
6
|
+
#
|
|
7
|
+
# There are no regular expressions here on purpose (no ReDoS on hostile commit
|
|
8
|
+
# messages) and no free-text heuristics: ordinary wording such as
|
|
9
|
+
# "feat: use AI service for recommendations" is never evidence.
|
|
10
|
+
|
|
11
|
+
schema_version: 1
|
|
12
|
+
|
|
13
|
+
# Trailers whose values are co-author identities (CoauthorDetector -> ai_coauthor).
|
|
14
|
+
coauthor_trailer_keys:
|
|
15
|
+
- co-authored-by
|
|
16
|
+
- coauthored-by
|
|
17
|
+
- co-author
|
|
18
|
+
- co-authored
|
|
19
|
+
|
|
20
|
+
# Other trailers that can carry AI attribution (TrailerDetector -> ai_trailer).
|
|
21
|
+
attribution_trailers:
|
|
22
|
+
- id: ai_generated_key
|
|
23
|
+
keys: [ai-generated-by, ai-assisted-by, ai-generated, ai-assisted, ai-co-authored-by]
|
|
24
|
+
match: key_present
|
|
25
|
+
description: The trailer key itself declares AI generation or assistance.
|
|
26
|
+
|
|
27
|
+
- id: generated_by_ai
|
|
28
|
+
keys: [generated-by, generated-with, assisted-by, created-by, made-with, co-developed-by]
|
|
29
|
+
match: ai_identity
|
|
30
|
+
description: A generation/assistance trailer names an AI agent.
|
|
31
|
+
|
|
32
|
+
- id: ai_reviewer_or_signoff
|
|
33
|
+
keys: [reviewed-by, signed-off-by, acked-by, tested-by, suggested-by, helped-by]
|
|
34
|
+
match: ai_identity
|
|
35
|
+
description: An AI agent is named in a review, sign-off or credit trailer.
|
|
36
|
+
|
|
37
|
+
# Trailers checked for malformed values (TrailerDetector -> malformed_trailer).
|
|
38
|
+
malformed_trailer_checks:
|
|
39
|
+
- keys: [co-authored-by, coauthored-by, co-author, co-authored]
|
|
40
|
+
require_identity: true # value must be "Name <email>"
|
|
41
|
+
- keys: [signed-off-by, reviewed-by, generated-by, assisted-by, ai-generated-by]
|
|
42
|
+
require_identity: false # only structural problems (missing colon, odd key)
|
|
43
|
+
|
|
44
|
+
# Exact, whole-line attribution footers inserted by tools (TrailerDetector ->
|
|
45
|
+
# ai_trailer). Compared after normalisation with leading emoji/symbols removed.
|
|
46
|
+
message_markers:
|
|
47
|
+
- id: claude_code_footer
|
|
48
|
+
agent: claude
|
|
49
|
+
lines:
|
|
50
|
+
- "Generated with [Claude Code](https://claude.com/claude-code)"
|
|
51
|
+
- "Generated with [Claude Code](https://claude.ai/code)"
|
|
52
|
+
- "Generated with Claude Code"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Load and validate rule files.
|
|
2
|
+
|
|
3
|
+
Rule files are data, parsed with the strict safe YAML loader and validated
|
|
4
|
+
against :mod:`commitguard.rules.models`. Built-in rules ship inside the wheel
|
|
5
|
+
(``commitguard/rules/data``); in a source checkout they are read from the
|
|
6
|
+
repository's top-level ``rules/`` directory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from functools import cache
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from pydantic import BaseModel, ValidationError
|
|
15
|
+
|
|
16
|
+
from commitguard.exceptions.base import UnsafeInputError
|
|
17
|
+
from commitguard.exceptions.configuration import RulesError
|
|
18
|
+
from commitguard.rules.matcher import CompiledRules
|
|
19
|
+
from commitguard.rules.models import (
|
|
20
|
+
AI_DOMAINS_FILE,
|
|
21
|
+
AI_IDENTITIES_FILE,
|
|
22
|
+
BOT_IDENTITIES_FILE,
|
|
23
|
+
PATTERNS_FILE,
|
|
24
|
+
AIDomainRules,
|
|
25
|
+
AIIdentityRules,
|
|
26
|
+
BotIdentityRules,
|
|
27
|
+
PatternRules,
|
|
28
|
+
RuleSet,
|
|
29
|
+
)
|
|
30
|
+
from commitguard.security.safe_yaml import load_yaml
|
|
31
|
+
from commitguard.utils.filesystem import read_text_limited
|
|
32
|
+
|
|
33
|
+
MAX_RULE_FILE_BYTES = 1024 * 1024
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def builtin_rules_dir() -> Path:
|
|
37
|
+
"""Directory containing the built-in rule files."""
|
|
38
|
+
packaged = Path(__file__).resolve().parent / "data"
|
|
39
|
+
if packaged.is_dir():
|
|
40
|
+
return packaged
|
|
41
|
+
source_checkout = Path(__file__).resolve().parents[3] / "rules"
|
|
42
|
+
if (source_checkout / AI_IDENTITIES_FILE).is_file():
|
|
43
|
+
return source_checkout
|
|
44
|
+
raise RulesError("built-in rule files not found (broken installation?)")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_file[M: BaseModel](directory: Path, filename: str, model: type[M]) -> M:
|
|
48
|
+
path = directory / filename
|
|
49
|
+
try:
|
|
50
|
+
text = read_text_limited(path, max_bytes=MAX_RULE_FILE_BYTES)
|
|
51
|
+
document = load_yaml(text)
|
|
52
|
+
return model.model_validate(document)
|
|
53
|
+
except FileNotFoundError as exc:
|
|
54
|
+
raise RulesError("rule file not found", path=path) from exc
|
|
55
|
+
except (OSError, UnsafeInputError) as exc:
|
|
56
|
+
raise RulesError(str(exc), path=path) from exc
|
|
57
|
+
except yaml.YAMLError as exc:
|
|
58
|
+
raise RulesError(f"invalid YAML: {exc}", path=path) from exc
|
|
59
|
+
except ValidationError as exc:
|
|
60
|
+
raise RulesError(_format(exc), path=path) from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_rules(directory: Path) -> CompiledRules:
|
|
64
|
+
"""Load, validate and cross-check every rule file in ``directory``."""
|
|
65
|
+
try:
|
|
66
|
+
rules = RuleSet(
|
|
67
|
+
ai_identities=_load_file(directory, AI_IDENTITIES_FILE, AIIdentityRules),
|
|
68
|
+
ai_domains=_load_file(directory, AI_DOMAINS_FILE, AIDomainRules),
|
|
69
|
+
bots=_load_file(directory, BOT_IDENTITIES_FILE, BotIdentityRules),
|
|
70
|
+
patterns=_load_file(directory, PATTERNS_FILE, PatternRules),
|
|
71
|
+
)
|
|
72
|
+
return CompiledRules(rules)
|
|
73
|
+
except ValidationError as exc:
|
|
74
|
+
raise RulesError(_format(exc), path=directory) from exc
|
|
75
|
+
except ValueError as exc: # conflicting aliases detected while compiling
|
|
76
|
+
raise RulesError(str(exc), path=directory) from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@cache
|
|
80
|
+
def builtin_rules_fingerprint() -> str:
|
|
81
|
+
"""SHA-256 over the built-in rule files: the rules version recorded with each scan."""
|
|
82
|
+
directory = builtin_rules_dir()
|
|
83
|
+
digest = hashlib.sha256()
|
|
84
|
+
for name in sorted((AI_IDENTITIES_FILE, AI_DOMAINS_FILE, BOT_IDENTITIES_FILE, PATTERNS_FILE)):
|
|
85
|
+
data = read_text_limited(directory / name, max_bytes=MAX_RULE_FILE_BYTES).encode("utf-8")
|
|
86
|
+
digest.update(f"{name}\0{len(data)}\0".encode())
|
|
87
|
+
digest.update(data)
|
|
88
|
+
return digest.hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cache
|
|
92
|
+
def load_builtin_rules() -> CompiledRules:
|
|
93
|
+
"""Load the built-in rules once per process."""
|
|
94
|
+
return load_rules(builtin_rules_dir())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _format(exc: ValidationError) -> str:
|
|
98
|
+
lines = ["invalid rules:"]
|
|
99
|
+
for error in exc.errors(include_url=False, include_input=False):
|
|
100
|
+
location = ".".join(str(part) for part in error["loc"]) or "<root>"
|
|
101
|
+
lines.append(f" - {location}: {error['msg']}")
|
|
102
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Deterministic identity matching against rule data.
|
|
2
|
+
|
|
3
|
+
Matching is exact on normalised values (see
|
|
4
|
+
:mod:`commitguard.provenance.normalization`) - never substring or fuzzy - and
|
|
5
|
+
combines evidence deliberately:
|
|
6
|
+
|
|
7
|
+
========================================== ============ ==================
|
|
8
|
+
Evidence Alone Confidence
|
|
9
|
+
========================================== ============ ==================
|
|
10
|
+
exact configured email match high
|
|
11
|
+
GitHub login (noreply address or name) match high
|
|
12
|
+
distinctive name prefix (``Claude Opus``) match high
|
|
13
|
+
automation email at vendor domain match high
|
|
14
|
+
full-name alias (``Claude``) match medium
|
|
15
|
+
full-name alias + vendor domain match high
|
|
16
|
+
ambiguous alias (``Devin``) + vendor domain match medium
|
|
17
|
+
ambiguous alias alone no match -
|
|
18
|
+
vendor domain alone (``jane@anthropic.com``) no match -
|
|
19
|
+
========================================== ============ ==================
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ConfigDict
|
|
26
|
+
|
|
27
|
+
from commitguard.core.result import Confidence, MatchKind, MatchReason
|
|
28
|
+
from commitguard.provenance.author import github_login
|
|
29
|
+
from commitguard.provenance.normalization import (
|
|
30
|
+
name_tokens,
|
|
31
|
+
normalize_domain,
|
|
32
|
+
normalize_email,
|
|
33
|
+
normalize_name,
|
|
34
|
+
)
|
|
35
|
+
from commitguard.rules.models import (
|
|
36
|
+
AI_DOMAINS_FILE,
|
|
37
|
+
AI_IDENTITIES_FILE,
|
|
38
|
+
BOT_IDENTITIES_FILE,
|
|
39
|
+
AIDomainRules,
|
|
40
|
+
DomainRule,
|
|
41
|
+
IdentityRule,
|
|
42
|
+
RuleSet,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_STRONG = frozenset(
|
|
46
|
+
{
|
|
47
|
+
MatchKind.EMAIL,
|
|
48
|
+
MatchKind.GITHUB_LOGIN,
|
|
49
|
+
MatchKind.NAME_PREFIX,
|
|
50
|
+
MatchKind.AUTOMATION_EMAIL,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class IdentityMatch(BaseModel):
|
|
56
|
+
"""The rule an identity matched, how confidently, and why."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
59
|
+
|
|
60
|
+
rule_id: str
|
|
61
|
+
display_name: str
|
|
62
|
+
vendor: str
|
|
63
|
+
confidence: Confidence
|
|
64
|
+
reasons: tuple[MatchReason, ...]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class IdentityMatcher:
|
|
68
|
+
"""Match ``(name, email)`` pairs against identity rules (and optional domains)."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
rules: Sequence[IdentityRule],
|
|
73
|
+
*,
|
|
74
|
+
source: str,
|
|
75
|
+
domains: AIDomainRules | None = None,
|
|
76
|
+
domain_source: str = "",
|
|
77
|
+
) -> None:
|
|
78
|
+
self._rules = {rule.id: rule for rule in rules}
|
|
79
|
+
self._source = source
|
|
80
|
+
self._domain_source = domain_source
|
|
81
|
+
self._names = _index(rules, lambda r: r.names, normalize_name)
|
|
82
|
+
self._ambiguous = _index(rules, lambda r: r.ambiguous_names, normalize_name)
|
|
83
|
+
self._emails = _index(rules, lambda r: r.emails, normalize_email)
|
|
84
|
+
self._logins = _index(rules, lambda r: r.github_logins, normalize_name)
|
|
85
|
+
self._prefixes = sorted(
|
|
86
|
+
(name_tokens(prefix), rule.id) for rule in rules for prefix in rule.name_prefixes
|
|
87
|
+
)
|
|
88
|
+
self._automation_local_parts = frozenset(domains.automation_local_parts if domains else ())
|
|
89
|
+
self._domains: dict[str, DomainRule] = {
|
|
90
|
+
d.domain: d for d in (domains.domains if domains else ()) if d.agent in self._rules
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def match(self, name: str | None, email: str | None) -> IdentityMatch | None:
|
|
94
|
+
reasons: dict[str, list[MatchReason]] = defaultdict(list)
|
|
95
|
+
|
|
96
|
+
if name:
|
|
97
|
+
normalized = normalize_name(name)
|
|
98
|
+
self._lookup(reasons, self._names, normalized, MatchKind.NAME)
|
|
99
|
+
self._lookup(reasons, self._ambiguous, normalized, MatchKind.AMBIGUOUS_NAME)
|
|
100
|
+
if normalized.endswith("[bot]"): # a GitHub App account name used verbatim
|
|
101
|
+
self._lookup(reasons, self._logins, normalized, MatchKind.GITHUB_LOGIN)
|
|
102
|
+
tokens = tuple(normalized.split())
|
|
103
|
+
for prefix, rule_id in self._prefixes:
|
|
104
|
+
if tokens[: len(prefix)] == prefix and len(tokens) >= len(prefix):
|
|
105
|
+
reasons[rule_id].append(
|
|
106
|
+
self._reason(MatchKind.NAME_PREFIX, " ".join(prefix), rule_id)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if email:
|
|
110
|
+
normalized_email = normalize_email(email)
|
|
111
|
+
self._lookup(reasons, self._emails, normalized_email, MatchKind.EMAIL)
|
|
112
|
+
login = github_login(normalized_email)
|
|
113
|
+
if login is not None:
|
|
114
|
+
self._lookup(reasons, self._logins, normalize_name(login), MatchKind.GITHUB_LOGIN)
|
|
115
|
+
self._match_domain(reasons, normalized_email)
|
|
116
|
+
|
|
117
|
+
candidates = [
|
|
118
|
+
match
|
|
119
|
+
for rule_id, rule_reasons in reasons.items()
|
|
120
|
+
if (match := self._evaluate(rule_id, rule_reasons)) is not None
|
|
121
|
+
]
|
|
122
|
+
if not candidates:
|
|
123
|
+
return None
|
|
124
|
+
# Deterministic: strongest confidence, then most reasons, then rule id.
|
|
125
|
+
return min(candidates, key=lambda m: (-m.confidence.rank, -len(m.reasons), m.rule_id))
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------ #
|
|
128
|
+
def _reason(self, kind: MatchKind, value: str, rule_id: str) -> MatchReason:
|
|
129
|
+
return MatchReason(kind=kind, value=value, rule=f"{self._source}#{rule_id}")
|
|
130
|
+
|
|
131
|
+
def _lookup(
|
|
132
|
+
self,
|
|
133
|
+
reasons: dict[str, list[MatchReason]],
|
|
134
|
+
index: dict[str, str],
|
|
135
|
+
value: str,
|
|
136
|
+
kind: MatchKind,
|
|
137
|
+
) -> None:
|
|
138
|
+
rule_id = index.get(value)
|
|
139
|
+
if rule_id is not None:
|
|
140
|
+
reasons[rule_id].append(self._reason(kind, value, rule_id))
|
|
141
|
+
|
|
142
|
+
def _match_domain(self, reasons: dict[str, list[MatchReason]], email: str) -> None:
|
|
143
|
+
local, _, domain = email.rpartition("@")
|
|
144
|
+
domain = normalize_domain(domain)
|
|
145
|
+
rule = self._domains.get(domain)
|
|
146
|
+
if rule is None:
|
|
147
|
+
rule = next(
|
|
148
|
+
(
|
|
149
|
+
d
|
|
150
|
+
for d in self._domains.values()
|
|
151
|
+
if d.include_subdomains and domain.endswith("." + d.domain)
|
|
152
|
+
),
|
|
153
|
+
None,
|
|
154
|
+
)
|
|
155
|
+
if rule is None or not local:
|
|
156
|
+
return
|
|
157
|
+
base_local = local.split("+", 1)[0]
|
|
158
|
+
if rule.local_parts == "any" or base_local in self._automation_local_parts:
|
|
159
|
+
kind, value = MatchKind.AUTOMATION_EMAIL, email
|
|
160
|
+
else:
|
|
161
|
+
kind, value = MatchKind.VENDOR_DOMAIN, domain
|
|
162
|
+
reference = f"{self._domain_source}#{rule.domain}"
|
|
163
|
+
reasons[rule.agent].append(MatchReason(kind=kind, value=value, rule=reference))
|
|
164
|
+
|
|
165
|
+
def _evaluate(self, rule_id: str, rule_reasons: list[MatchReason]) -> IdentityMatch | None:
|
|
166
|
+
kinds = {reason.kind for reason in rule_reasons}
|
|
167
|
+
if kinds & _STRONG or {MatchKind.NAME, MatchKind.VENDOR_DOMAIN} <= kinds:
|
|
168
|
+
confidence = Confidence.HIGH
|
|
169
|
+
elif (
|
|
170
|
+
MatchKind.NAME in kinds or {MatchKind.AMBIGUOUS_NAME, MatchKind.VENDOR_DOMAIN} <= kinds
|
|
171
|
+
):
|
|
172
|
+
confidence = Confidence.MEDIUM
|
|
173
|
+
else:
|
|
174
|
+
return None
|
|
175
|
+
rule = self._rules[rule_id]
|
|
176
|
+
return IdentityMatch(
|
|
177
|
+
rule_id=rule.id,
|
|
178
|
+
display_name=rule.display_name,
|
|
179
|
+
vendor=rule.vendor,
|
|
180
|
+
confidence=confidence,
|
|
181
|
+
reasons=tuple(dict.fromkeys(rule_reasons)),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _index(
|
|
186
|
+
rules: Iterable[IdentityRule],
|
|
187
|
+
values: Callable[[IdentityRule], tuple[str, ...]],
|
|
188
|
+
normalize: Callable[[str], str],
|
|
189
|
+
) -> dict[str, str]:
|
|
190
|
+
index: dict[str, str] = {}
|
|
191
|
+
for rule in rules:
|
|
192
|
+
for value in values(rule):
|
|
193
|
+
key = normalize(value)
|
|
194
|
+
existing = index.setdefault(key, rule.id)
|
|
195
|
+
if existing != rule.id:
|
|
196
|
+
raise ValueError(f"{value!r} is claimed by both {existing!r} and {rule.id!r}")
|
|
197
|
+
return index
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class CompiledRules:
|
|
201
|
+
"""A validated :class:`RuleSet` plus the matchers built from it (pure, no I/O)."""
|
|
202
|
+
|
|
203
|
+
def __init__(self, rules: RuleSet) -> None:
|
|
204
|
+
self.rules = rules
|
|
205
|
+
self.ai_matcher = IdentityMatcher(
|
|
206
|
+
rules.ai_identities.agents,
|
|
207
|
+
source=AI_IDENTITIES_FILE,
|
|
208
|
+
domains=rules.ai_domains,
|
|
209
|
+
domain_source=AI_DOMAINS_FILE,
|
|
210
|
+
)
|
|
211
|
+
self.bot_matcher = IdentityMatcher(rules.bots.bots, source=BOT_IDENTITIES_FILE)
|
|
212
|
+
self.agents = {agent.id: agent for agent in rules.ai_identities.agents}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Schemas for rule files. Every model forbids unknown keys."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
6
|
+
|
|
7
|
+
from commitguard.provenance.author import is_plausible_email
|
|
8
|
+
from commitguard.provenance.normalization import (
|
|
9
|
+
normalize_domain,
|
|
10
|
+
normalize_email,
|
|
11
|
+
normalize_name,
|
|
12
|
+
normalize_text,
|
|
13
|
+
normalize_trailer_key,
|
|
14
|
+
)
|
|
15
|
+
from commitguard.security.validation import validate_identifier
|
|
16
|
+
|
|
17
|
+
_STRICT = ConfigDict(frozen=True, extra="forbid")
|
|
18
|
+
|
|
19
|
+
AI_IDENTITIES_FILE = "ai-identities.yaml"
|
|
20
|
+
AI_DOMAINS_FILE = "ai-domains.yaml"
|
|
21
|
+
BOT_IDENTITIES_FILE = "bot-identities.yaml"
|
|
22
|
+
PATTERNS_FILE = "patterns.yaml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _non_empty_after_normalization(values: tuple[str, ...], kind: str) -> tuple[str, ...]:
|
|
26
|
+
for value in values:
|
|
27
|
+
if not normalize_text(value):
|
|
28
|
+
raise ValueError(f"{kind} entries must not be empty")
|
|
29
|
+
return values
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class IdentityRule(BaseModel):
|
|
33
|
+
"""An identity (AI agent or bot) described by aliases and addresses.
|
|
34
|
+
|
|
35
|
+
* ``names`` - full-name aliases; a match alone is evidence (medium confidence).
|
|
36
|
+
* ``ambiguous_names`` - aliases that are also common human names; only count
|
|
37
|
+
together with an address/domain of the same agent.
|
|
38
|
+
* ``name_prefixes`` - distinctive leading words (``Claude Opus`` matches
|
|
39
|
+
``Claude Opus 4.5``); a match alone is strong evidence.
|
|
40
|
+
* ``emails`` / ``github_logins`` - exact identifiers; strong evidence.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
model_config = _STRICT
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
display_name: str = Field(min_length=1)
|
|
47
|
+
vendor: str = ""
|
|
48
|
+
names: tuple[str, ...] = ()
|
|
49
|
+
ambiguous_names: tuple[str, ...] = ()
|
|
50
|
+
name_prefixes: tuple[str, ...] = ()
|
|
51
|
+
emails: tuple[str, ...] = ()
|
|
52
|
+
github_logins: tuple[str, ...] = ()
|
|
53
|
+
verified: bool = False
|
|
54
|
+
reference: str = ""
|
|
55
|
+
|
|
56
|
+
@field_validator("id")
|
|
57
|
+
@classmethod
|
|
58
|
+
def _valid_id(cls, value: str) -> str:
|
|
59
|
+
return validate_identifier(value, kind="rule id")
|
|
60
|
+
|
|
61
|
+
@field_validator("names", "ambiguous_names", "name_prefixes", "github_logins")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _non_empty(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
64
|
+
return _non_empty_after_normalization(value, "name/login")
|
|
65
|
+
|
|
66
|
+
@field_validator("emails")
|
|
67
|
+
@classmethod
|
|
68
|
+
def _valid_emails(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
69
|
+
for email in value:
|
|
70
|
+
if not is_plausible_email(normalize_email(email)):
|
|
71
|
+
raise ValueError(f"invalid email {email!r}")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
@model_validator(mode="after")
|
|
75
|
+
def _has_identifiers(self) -> "IdentityRule":
|
|
76
|
+
if not (self.names or self.name_prefixes or self.emails or self.github_logins):
|
|
77
|
+
raise ValueError(f"rule {self.id!r} needs names, name_prefixes, emails or logins")
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AIIdentityRules(BaseModel):
|
|
82
|
+
model_config = _STRICT
|
|
83
|
+
|
|
84
|
+
schema_version: Literal[1]
|
|
85
|
+
agents: tuple[IdentityRule, ...] = Field(min_length=1)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DomainRule(BaseModel):
|
|
89
|
+
"""A vendor email domain associated with one agent.
|
|
90
|
+
|
|
91
|
+
A domain alone is never enough: ``local_parts: automation`` (the default)
|
|
92
|
+
means only automation-style local parts (``noreply@``) are strong evidence;
|
|
93
|
+
other addresses at the domain only corroborate a name match.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
model_config = _STRICT
|
|
97
|
+
|
|
98
|
+
domain: str
|
|
99
|
+
agent: str
|
|
100
|
+
local_parts: Literal["automation", "any"] = "automation"
|
|
101
|
+
include_subdomains: bool = False
|
|
102
|
+
|
|
103
|
+
@field_validator("domain")
|
|
104
|
+
@classmethod
|
|
105
|
+
def _valid_domain(cls, value: str) -> str:
|
|
106
|
+
normalized = normalize_domain(value)
|
|
107
|
+
if "." not in normalized or normalized != value:
|
|
108
|
+
raise ValueError(f"domain {value!r} must be lowercase and fully qualified")
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AIDomainRules(BaseModel):
|
|
113
|
+
model_config = _STRICT
|
|
114
|
+
|
|
115
|
+
schema_version: Literal[1]
|
|
116
|
+
automation_local_parts: tuple[str, ...] = Field(min_length=1)
|
|
117
|
+
domains: tuple[DomainRule, ...] = ()
|
|
118
|
+
|
|
119
|
+
@field_validator("automation_local_parts")
|
|
120
|
+
@classmethod
|
|
121
|
+
def _normalized_local_parts(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
122
|
+
for part in value:
|
|
123
|
+
if not part or part != normalize_text(part) or "@" in part or " " in part:
|
|
124
|
+
raise ValueError(f"invalid automation local part {part!r}")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class BotIdentityRules(BaseModel):
|
|
129
|
+
model_config = _STRICT
|
|
130
|
+
|
|
131
|
+
schema_version: Literal[1]
|
|
132
|
+
bots: tuple[IdentityRule, ...] = ()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _canonical_keys(keys: tuple[str, ...]) -> tuple[str, ...]:
|
|
136
|
+
for key in keys:
|
|
137
|
+
if key != normalize_trailer_key(key) or not key:
|
|
138
|
+
raise ValueError(f"trailer key {key!r} must be canonical (e.g. 'co-authored-by')")
|
|
139
|
+
return keys
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TrailerRule(BaseModel):
|
|
143
|
+
"""A trailer that indicates AI attribution.
|
|
144
|
+
|
|
145
|
+
``match: key_present`` - the key itself is attribution (``AI-generated-by``).
|
|
146
|
+
``match: ai_identity`` - only when the value matches an AI identity rule
|
|
147
|
+
(``Generated-by: protoc`` is fine, ``Generated-by: Claude Code`` is not).
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
model_config = _STRICT
|
|
151
|
+
|
|
152
|
+
id: str
|
|
153
|
+
keys: tuple[str, ...] = Field(min_length=1)
|
|
154
|
+
match: Literal["key_present", "ai_identity"]
|
|
155
|
+
description: str = Field(min_length=1)
|
|
156
|
+
ignore_values: tuple[str, ...] = Field(
|
|
157
|
+
default=("false", "no", "none", "n/a", "0"),
|
|
158
|
+
description="Values (normalised) that negate the key, e.g. 'AI-assisted: no'",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@field_validator("id")
|
|
162
|
+
@classmethod
|
|
163
|
+
def _valid_id(cls, value: str) -> str:
|
|
164
|
+
return validate_identifier(value, kind="trailer rule id")
|
|
165
|
+
|
|
166
|
+
@field_validator("keys")
|
|
167
|
+
@classmethod
|
|
168
|
+
def _canonical(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
169
|
+
return _canonical_keys(value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class MalformedTrailerCheck(BaseModel):
|
|
173
|
+
"""Which trailer keys are checked for malformed structure/identities."""
|
|
174
|
+
|
|
175
|
+
model_config = _STRICT
|
|
176
|
+
|
|
177
|
+
keys: tuple[str, ...] = Field(min_length=1)
|
|
178
|
+
require_identity: bool
|
|
179
|
+
|
|
180
|
+
@field_validator("keys")
|
|
181
|
+
@classmethod
|
|
182
|
+
def _canonical(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
183
|
+
return _canonical_keys(value)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class MessageMarkerRule(BaseModel):
|
|
187
|
+
"""Exact attribution lines inserted by tools (compared after normalisation).
|
|
188
|
+
|
|
189
|
+
Lines must match *entirely*; there is no substring search, so ordinary
|
|
190
|
+
wording such as "use AI service for recommendations" never matches.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
model_config = _STRICT
|
|
194
|
+
|
|
195
|
+
id: str
|
|
196
|
+
agent: str
|
|
197
|
+
lines: tuple[str, ...] = Field(min_length=1)
|
|
198
|
+
|
|
199
|
+
@field_validator("id")
|
|
200
|
+
@classmethod
|
|
201
|
+
def _valid_id(cls, value: str) -> str:
|
|
202
|
+
return validate_identifier(value, kind="marker id")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class PatternRules(BaseModel):
|
|
206
|
+
model_config = _STRICT
|
|
207
|
+
|
|
208
|
+
schema_version: Literal[1]
|
|
209
|
+
coauthor_trailer_keys: tuple[str, ...] = Field(min_length=1)
|
|
210
|
+
attribution_trailers: tuple[TrailerRule, ...] = ()
|
|
211
|
+
malformed_trailer_checks: tuple[MalformedTrailerCheck, ...] = ()
|
|
212
|
+
message_markers: tuple[MessageMarkerRule, ...] = ()
|
|
213
|
+
|
|
214
|
+
@field_validator("coauthor_trailer_keys")
|
|
215
|
+
@classmethod
|
|
216
|
+
def _canonical(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
217
|
+
return _canonical_keys(value)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class RuleSet(BaseModel):
|
|
221
|
+
"""All rule files, cross-validated."""
|
|
222
|
+
|
|
223
|
+
model_config = _STRICT
|
|
224
|
+
|
|
225
|
+
ai_identities: AIIdentityRules
|
|
226
|
+
ai_domains: AIDomainRules
|
|
227
|
+
bots: BotIdentityRules
|
|
228
|
+
patterns: PatternRules
|
|
229
|
+
|
|
230
|
+
@model_validator(mode="after")
|
|
231
|
+
def _cross_references(self) -> "RuleSet":
|
|
232
|
+
agent_ids = [agent.id for agent in self.ai_identities.agents]
|
|
233
|
+
_require_unique(agent_ids, "agent id")
|
|
234
|
+
_require_unique([bot.id for bot in self.bots.bots], "bot id")
|
|
235
|
+
known = set(agent_ids)
|
|
236
|
+
for domain in self.ai_domains.domains:
|
|
237
|
+
if domain.agent not in known:
|
|
238
|
+
raise ValueError(f"domain {domain.domain!r} references unknown agent")
|
|
239
|
+
_require_unique([d.domain for d in self.ai_domains.domains], "domain")
|
|
240
|
+
for marker in self.patterns.message_markers:
|
|
241
|
+
if marker.agent not in known:
|
|
242
|
+
raise ValueError(f"message marker {marker.id!r} references unknown agent")
|
|
243
|
+
_require_unique([r.id for r in self.patterns.attribution_trailers], "trailer rule id")
|
|
244
|
+
_require_unique(
|
|
245
|
+
[key for rule in self.patterns.attribution_trailers for key in rule.keys],
|
|
246
|
+
"attribution trailer key",
|
|
247
|
+
)
|
|
248
|
+
coauthor = set(self.patterns.coauthor_trailer_keys)
|
|
249
|
+
for rule in self.patterns.attribution_trailers:
|
|
250
|
+
if coauthor & set(rule.keys):
|
|
251
|
+
raise ValueError("co-author keys are handled by the coauthor detector")
|
|
252
|
+
# Aliases shared between agents would make attribution ambiguous.
|
|
253
|
+
_require_unique(
|
|
254
|
+
[
|
|
255
|
+
normalize_name(alias)
|
|
256
|
+
for agent in self.ai_identities.agents
|
|
257
|
+
for alias in (*agent.names, *agent.ambiguous_names, *agent.name_prefixes)
|
|
258
|
+
],
|
|
259
|
+
"AI agent alias",
|
|
260
|
+
)
|
|
261
|
+
return self
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _require_unique(values: list[str], kind: str) -> None:
|
|
265
|
+
seen: set[str] = set()
|
|
266
|
+
for value in values:
|
|
267
|
+
if value in seen:
|
|
268
|
+
raise ValueError(f"duplicate {kind} {value!r}")
|
|
269
|
+
seen.add(value)
|