octp-python 0.2.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.
- octp/__init__.py +9 -0
- octp/cli/__init__.py +0 -0
- octp/cli/init.py +54 -0
- octp/cli/main.py +19 -0
- octp/cli/sign.py +133 -0
- octp/cli/verify.py +61 -0
- octp/core/__init__.py +0 -0
- octp/core/builder.py +103 -0
- octp/core/envelope.py +90 -0
- octp/core/validator.py +28 -0
- octp/git/__init__.py +0 -0
- octp/git/reader.py +64 -0
- octp/identity/__init__.py +0 -0
- octp/identity/keymanager.py +75 -0
- octp/identity/resolver.py +41 -0
- octp/integrity/__init__.py +0 -0
- octp/integrity/hasher.py +14 -0
- octp/output/__init__.py +0 -0
- octp/output/formatter.py +83 -0
- octp/output/writer.py +15 -0
- octp/provenance/__init__.py +0 -0
- octp/provenance/collector.py +119 -0
- octp/provenance/models.py +8 -0
- octp/verification/__init__.py +0 -0
- octp/verification/bandit_runner.py +38 -0
- octp/verification/base.py +33 -0
- octp/verification/deps_runner.py +38 -0
- octp/verification/detect_secrets_runner.py +51 -0
- octp/verification/mypy_runner.py +44 -0
- octp/verification/pytest_runner.py +68 -0
- octp/verification/registry.py +130 -0
- octp/verification/ruff_runner.py +44 -0
- octp/verification/safety_runner.py +39 -0
- octp/verification/semgrep_runner.py +46 -0
- octp_python-0.2.0.dist-info/METADATA +319 -0
- octp_python-0.2.0.dist-info/RECORD +39 -0
- octp_python-0.2.0.dist-info/WHEEL +4 -0
- octp_python-0.2.0.dist-info/entry_points.txt +2 -0
- octp_python-0.2.0.dist-info/licenses/LICENSE +21 -0
octp/__init__.py
ADDED
octp/cli/__init__.py
ADDED
|
File without changes
|
octp/cli/init.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.prompt import Confirm
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG = """\
|
|
12
|
+
[policy]
|
|
13
|
+
require_envelope = true
|
|
14
|
+
minimum_review_level = "moderate_review"
|
|
15
|
+
block_on_failed_tests = true
|
|
16
|
+
allow_unreviewed_ai = false
|
|
17
|
+
|
|
18
|
+
[runners]
|
|
19
|
+
test_runner = "pytest"
|
|
20
|
+
static_analysis = "semgrep"
|
|
21
|
+
dependency_check = "pip-audit"
|
|
22
|
+
|
|
23
|
+
[identity]
|
|
24
|
+
require_signed_envelope = true
|
|
25
|
+
key_registry = "github"
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def init_command(
|
|
30
|
+
path: Path = typer.Argument(
|
|
31
|
+
Path("."), help="Repository path to initialise OCTP in"
|
|
32
|
+
),
|
|
33
|
+
):
|
|
34
|
+
"""Initialise OCTP in a repository — creates .octp.toml"""
|
|
35
|
+
|
|
36
|
+
config_path = path / ".octp.toml"
|
|
37
|
+
|
|
38
|
+
if config_path.exists():
|
|
39
|
+
overwrite = Confirm.ask(
|
|
40
|
+
f".octp.toml already exists at {config_path}. Overwrite?"
|
|
41
|
+
)
|
|
42
|
+
if not overwrite:
|
|
43
|
+
console.print("Aborted.")
|
|
44
|
+
raise typer.Exit(0)
|
|
45
|
+
|
|
46
|
+
config_path.write_text(DEFAULT_CONFIG)
|
|
47
|
+
console.print(f"\n[green]✓[/green] Created {config_path}")
|
|
48
|
+
console.print("\nNext steps:")
|
|
49
|
+
console.print(" 1. Review and adjust .octp.toml for your project")
|
|
50
|
+
console.print(" 2. Run [cyan]octp sign[/cyan] before submitting a pull request")
|
|
51
|
+
console.print(
|
|
52
|
+
" 3. Add to your CONTRIBUTING.md that contributors should run octp sign"
|
|
53
|
+
)
|
|
54
|
+
console.print("\nSpec: https://github.com/openoctp/spec")
|
octp/cli/main.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from octp.cli.init import init_command
|
|
4
|
+
from octp.cli.sign import sign_command
|
|
5
|
+
from octp.cli.verify import verify_command
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
name="octp",
|
|
9
|
+
help="Open Contribution Trust Protocol — generate and verify trust envelopes",
|
|
10
|
+
add_completion=False,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
app.command(name="sign")(sign_command)
|
|
14
|
+
app.command(name="verify")(verify_command)
|
|
15
|
+
app.command(name="init")(init_command)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
app()
|
octp/cli/sign.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from octp.core.builder import build_envelope
|
|
10
|
+
from octp.git.reader import read_repo
|
|
11
|
+
from octp.identity.keymanager import ensure_keypair
|
|
12
|
+
from octp.identity.resolver import resolve_developer_id
|
|
13
|
+
from octp.output.formatter import (
|
|
14
|
+
print_envelope_summary,
|
|
15
|
+
print_header,
|
|
16
|
+
print_success,
|
|
17
|
+
print_verification_results,
|
|
18
|
+
)
|
|
19
|
+
from octp.provenance.collector import collect_interactively
|
|
20
|
+
from octp.verification.registry import run_all
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_default_provenance() -> dict:
|
|
26
|
+
"""Get default provenance data for non-interactive mode.
|
|
27
|
+
|
|
28
|
+
For OCTP project: defaults to AI-assisted with substantial review.
|
|
29
|
+
"""
|
|
30
|
+
return {
|
|
31
|
+
"method": "ai_assisted_human_reviewed",
|
|
32
|
+
"ai_tools": [
|
|
33
|
+
{
|
|
34
|
+
"model": "claude-sonnet-4-6",
|
|
35
|
+
"vendor": "anthropic",
|
|
36
|
+
"version": "20260226",
|
|
37
|
+
"usage_type": "architecture_and_implementation",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"model": "kimi-k2.5",
|
|
41
|
+
"vendor": "moonshot",
|
|
42
|
+
"version": "20260226",
|
|
43
|
+
"usage_type": "implementation_and_scaffolding",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
"human_review_level": "substantial_modification",
|
|
47
|
+
"human_review_duration_minutes": None,
|
|
48
|
+
"optional_context": {},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_interactive() -> bool:
|
|
53
|
+
"""Check if running in an interactive terminal."""
|
|
54
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sign_command(
|
|
58
|
+
output: Path = typer.Option(
|
|
59
|
+
Path(".octp-envelope.json"),
|
|
60
|
+
"--output",
|
|
61
|
+
"-o",
|
|
62
|
+
help="Path to write the envelope JSON",
|
|
63
|
+
),
|
|
64
|
+
yes: bool = typer.Option(
|
|
65
|
+
False, "--yes", "-y", help="Skip interactive prompts — use defaults"
|
|
66
|
+
),
|
|
67
|
+
profile: str = typer.Option(
|
|
68
|
+
"full",
|
|
69
|
+
"--profile",
|
|
70
|
+
"-p",
|
|
71
|
+
help="Runner profile: fast (3-8s), full (all checks), ci, security",
|
|
72
|
+
),
|
|
73
|
+
):
|
|
74
|
+
"""Generate and sign a trust envelope for the current commit."""
|
|
75
|
+
|
|
76
|
+
print_header()
|
|
77
|
+
|
|
78
|
+
# Read git state
|
|
79
|
+
try:
|
|
80
|
+
repo_info = read_repo()
|
|
81
|
+
except RuntimeError as e:
|
|
82
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
console.print(f"\n Repository : [cyan]{repo_info.repository}[/cyan]")
|
|
86
|
+
console.print(f" Commit : [cyan]{repo_info.commit_hash[:12]}[/cyan]")
|
|
87
|
+
console.print(f" Profile : [cyan]{profile}[/cyan]")
|
|
88
|
+
|
|
89
|
+
# Resolve identity
|
|
90
|
+
ensure_keypair()
|
|
91
|
+
developer_id = resolve_developer_id()
|
|
92
|
+
console.print(f" Developer : [cyan]{developer_id}[/cyan]\n")
|
|
93
|
+
|
|
94
|
+
# Run verification checks
|
|
95
|
+
check_results = run_all(repo_info.root, profile=profile)
|
|
96
|
+
print_verification_results(check_results)
|
|
97
|
+
|
|
98
|
+
# Collect provenance declaration
|
|
99
|
+
if yes:
|
|
100
|
+
# Explicit --yes flag: use defaults
|
|
101
|
+
provenance_data = get_default_provenance()
|
|
102
|
+
console.print("\n[dim]Using default provenance (non-interactive mode)[/dim]")
|
|
103
|
+
elif not is_interactive():
|
|
104
|
+
# No TTY detected: use defaults with warning
|
|
105
|
+
provenance_data = get_default_provenance()
|
|
106
|
+
console.print(
|
|
107
|
+
"\n[yellow]Warning:[/yellow] Non-interactive mode detected. "
|
|
108
|
+
"Using default provenance. Use --yes to suppress this warning."
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
# Interactive: collect from user
|
|
112
|
+
try:
|
|
113
|
+
provenance_data = collect_interactively()
|
|
114
|
+
except Exception as e:
|
|
115
|
+
console.print(f"\n[red]Error collecting input:[/red] {e}")
|
|
116
|
+
console.print("[dim]Falling back to default provenance...[/dim]")
|
|
117
|
+
provenance_data = get_default_provenance()
|
|
118
|
+
|
|
119
|
+
# Build and sign envelope
|
|
120
|
+
envelope = build_envelope(
|
|
121
|
+
repo_info=repo_info,
|
|
122
|
+
developer_id=developer_id,
|
|
123
|
+
provenance_data=provenance_data,
|
|
124
|
+
check_results=check_results,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Write envelope
|
|
128
|
+
envelope_json = envelope.model_dump_json(indent=2)
|
|
129
|
+
output.write_text(envelope_json)
|
|
130
|
+
|
|
131
|
+
# Print summary
|
|
132
|
+
print_envelope_summary(envelope)
|
|
133
|
+
print_success(str(output))
|
octp/cli/verify.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from octp.core.envelope import OCTPEnvelope
|
|
10
|
+
from octp.integrity.hasher import hash_payload
|
|
11
|
+
from octp.output.formatter import print_header, print_verify_result
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_command(
|
|
17
|
+
envelope_path: Path = typer.Argument(
|
|
18
|
+
..., help="Path to the envelope JSON file to verify"
|
|
19
|
+
),
|
|
20
|
+
):
|
|
21
|
+
"""Verify a trust envelope — check integrity and signature."""
|
|
22
|
+
|
|
23
|
+
print_header()
|
|
24
|
+
|
|
25
|
+
if not envelope_path.exists():
|
|
26
|
+
console.print(f"[red]Error:[/red] Envelope file not found: {envelope_path}")
|
|
27
|
+
raise typer.Exit(1)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(envelope_path.read_text())
|
|
31
|
+
envelope = OCTPEnvelope.model_validate(data)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print_verify_result(False, f"Could not parse envelope: {e}")
|
|
34
|
+
raise typer.Exit(1)
|
|
35
|
+
|
|
36
|
+
if not envelope.integrity:
|
|
37
|
+
print_verify_result(False, "No integrity section found in envelope")
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
|
|
40
|
+
# Recompute payload hash
|
|
41
|
+
payload_dict = envelope.to_signable_dict()
|
|
42
|
+
computed_hash = hash_payload(payload_dict)
|
|
43
|
+
|
|
44
|
+
if computed_hash != envelope.integrity.payload_hash:
|
|
45
|
+
print_verify_result(
|
|
46
|
+
False, "Payload hash mismatch — envelope has been tampered with"
|
|
47
|
+
)
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
|
|
50
|
+
# Note: full signature verification requires public key lookup
|
|
51
|
+
# In v0.1 we verify the hash integrity and flag if signature is present
|
|
52
|
+
console.print(f"\n Envelope : [cyan]{envelope_path}[/cyan]")
|
|
53
|
+
console.print(f" Developer : [cyan]{envelope.provenance.developer_id}[/cyan]")
|
|
54
|
+
console.print(f" Method : [cyan]{envelope.provenance.method.value}[/cyan]")
|
|
55
|
+
console.print(f" Commit : [cyan]{envelope.commit_hash[:12]}[/cyan]")
|
|
56
|
+
|
|
57
|
+
print_verify_result(True)
|
|
58
|
+
console.print(
|
|
59
|
+
"\n[dim]Note: v0.1 verifies payload integrity. "
|
|
60
|
+
"Full signature verification against public key registry coming in v0.2.[/dim]"
|
|
61
|
+
)
|
octp/core/__init__.py
ADDED
|
File without changes
|
octp/core/builder.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from octp.core.envelope import (
|
|
7
|
+
AnalysisResult,
|
|
8
|
+
Integrity,
|
|
9
|
+
OCTPEnvelope,
|
|
10
|
+
OptionalContext,
|
|
11
|
+
Provenance,
|
|
12
|
+
Verification,
|
|
13
|
+
)
|
|
14
|
+
from octp.git.reader import RepoInfo
|
|
15
|
+
from octp.identity.keymanager import sign_payload
|
|
16
|
+
from octp.integrity.hasher import hash_payload
|
|
17
|
+
from octp.verification.base import CheckResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_envelope(
|
|
21
|
+
repo_info: RepoInfo,
|
|
22
|
+
developer_id: str,
|
|
23
|
+
provenance_data: dict,
|
|
24
|
+
check_results: dict[str, CheckResult],
|
|
25
|
+
) -> OCTPEnvelope:
|
|
26
|
+
"""Assemble a complete signed OCTPEnvelope."""
|
|
27
|
+
|
|
28
|
+
# Build provenance
|
|
29
|
+
ai_tools = provenance_data.get("ai_tools")
|
|
30
|
+
provenance = Provenance(
|
|
31
|
+
method=provenance_data["method"],
|
|
32
|
+
ai_tools=ai_tools,
|
|
33
|
+
human_review_level=provenance_data["human_review_level"],
|
|
34
|
+
human_review_duration_minutes=provenance_data.get(
|
|
35
|
+
"human_review_duration_minutes"
|
|
36
|
+
),
|
|
37
|
+
developer_id=developer_id,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Build verification from check results
|
|
41
|
+
tests_result = check_results.get("pytest")
|
|
42
|
+
static_result = check_results.get("semgrep") or check_results.get("bandit")
|
|
43
|
+
deps_result = check_results.get("pip-audit")
|
|
44
|
+
|
|
45
|
+
verification = Verification(
|
|
46
|
+
tests_passed=tests_result.passed if tests_result else None,
|
|
47
|
+
test_suite_hash=tests_result.suite_hash if tests_result else None,
|
|
48
|
+
static_analysis=AnalysisResult(
|
|
49
|
+
"passed"
|
|
50
|
+
if static_result and static_result.passed
|
|
51
|
+
else "failed"
|
|
52
|
+
if static_result and not static_result.passed
|
|
53
|
+
else "skipped"
|
|
54
|
+
),
|
|
55
|
+
static_analysis_tool=static_result.tool_name if static_result else None,
|
|
56
|
+
dependency_check=AnalysisResult(
|
|
57
|
+
"passed"
|
|
58
|
+
if deps_result and deps_result.passed
|
|
59
|
+
else "failed"
|
|
60
|
+
if deps_result and not deps_result.passed
|
|
61
|
+
else "skipped"
|
|
62
|
+
),
|
|
63
|
+
novel_dependencies_introduced=False, # v0.1: always false, future runner
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Build optional context
|
|
67
|
+
ctx_data = provenance_data.get("optional_context", {})
|
|
68
|
+
optional_context = (
|
|
69
|
+
OptionalContext(
|
|
70
|
+
issue_reference=ctx_data.get("issue_reference"),
|
|
71
|
+
self_assessed_confidence=ctx_data.get("self_assessed_confidence"),
|
|
72
|
+
areas_of_uncertainty=ctx_data.get("areas_of_uncertainty"),
|
|
73
|
+
time_in_codebase_minutes=ctx_data.get("time_in_codebase_minutes"),
|
|
74
|
+
)
|
|
75
|
+
if ctx_data
|
|
76
|
+
else None
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Build unsigned envelope
|
|
80
|
+
envelope = OCTPEnvelope(
|
|
81
|
+
octp_version="0.1",
|
|
82
|
+
contribution_id=str(uuid.uuid4()),
|
|
83
|
+
timestamp=datetime.now(timezone.utc),
|
|
84
|
+
repository=repo_info.repository,
|
|
85
|
+
commit_hash=repo_info.commit_hash,
|
|
86
|
+
provenance=provenance,
|
|
87
|
+
verification=verification,
|
|
88
|
+
optional_context=optional_context,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Hash and sign
|
|
92
|
+
payload_dict = envelope.to_signable_dict()
|
|
93
|
+
payload_hash = hash_payload(payload_dict)
|
|
94
|
+
signature = sign_payload(payload_hash)
|
|
95
|
+
|
|
96
|
+
envelope.integrity = Integrity(
|
|
97
|
+
payload_hash=payload_hash,
|
|
98
|
+
developer_signature=signature,
|
|
99
|
+
signature_algorithm="ES256",
|
|
100
|
+
signed_at=datetime.now(timezone.utc),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return envelope
|
octp/core/envelope.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProvenanceMethod(str, Enum):
|
|
11
|
+
HUMAN_ONLY = "human_only"
|
|
12
|
+
AI_ASSISTED_HUMAN_REVIEWED = "ai_assisted_human_reviewed"
|
|
13
|
+
AI_GENERATED_HUMAN_REVIEWED = "ai_generated_human_reviewed"
|
|
14
|
+
AI_GENERATED_UNREVIEWED = "ai_generated_unreviewed"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReviewLevel(str, Enum):
|
|
18
|
+
NONE = "none"
|
|
19
|
+
GLANCE = "glance"
|
|
20
|
+
MODERATE = "moderate_review"
|
|
21
|
+
SUBSTANTIAL = "substantial_modification"
|
|
22
|
+
REWRITE = "complete_rewrite"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AnalysisResult(str, Enum):
|
|
26
|
+
PASSED = "passed"
|
|
27
|
+
FAILED = "failed"
|
|
28
|
+
SKIPPED = "skipped"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Confidence(str, Enum):
|
|
32
|
+
LOW = "low"
|
|
33
|
+
MEDIUM = "medium"
|
|
34
|
+
HIGH = "high"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AITool(BaseModel):
|
|
38
|
+
model: str
|
|
39
|
+
vendor: str
|
|
40
|
+
version: str
|
|
41
|
+
usage_type: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Provenance(BaseModel):
|
|
45
|
+
method: ProvenanceMethod
|
|
46
|
+
ai_tools: Optional[list[AITool]] = None
|
|
47
|
+
human_review_level: ReviewLevel
|
|
48
|
+
human_review_duration_minutes: Optional[int] = Field(None, ge=0)
|
|
49
|
+
developer_id: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Verification(BaseModel):
|
|
53
|
+
tests_passed: Optional[bool] = None # None = not run, True = passed, False = failed
|
|
54
|
+
test_suite_hash: Optional[str] = None
|
|
55
|
+
static_analysis: AnalysisResult
|
|
56
|
+
static_analysis_tool: Optional[str] = None
|
|
57
|
+
dependency_check: AnalysisResult
|
|
58
|
+
novel_dependencies_introduced: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Integrity(BaseModel):
|
|
62
|
+
payload_hash: str
|
|
63
|
+
developer_signature: str
|
|
64
|
+
signature_algorithm: str = "ES256"
|
|
65
|
+
signed_at: datetime
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class OptionalContext(BaseModel):
|
|
69
|
+
issue_reference: Optional[str] = None
|
|
70
|
+
self_assessed_confidence: Optional[Confidence] = None
|
|
71
|
+
areas_of_uncertainty: Optional[str] = None
|
|
72
|
+
time_in_codebase_minutes: Optional[int] = Field(None, ge=0)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class OCTPEnvelope(BaseModel):
|
|
76
|
+
octp_version: str = "0.1"
|
|
77
|
+
contribution_id: str
|
|
78
|
+
timestamp: datetime
|
|
79
|
+
repository: str
|
|
80
|
+
commit_hash: str
|
|
81
|
+
provenance: Provenance
|
|
82
|
+
verification: Verification
|
|
83
|
+
integrity: Optional[Integrity] = None
|
|
84
|
+
optional_context: Optional[OptionalContext] = None
|
|
85
|
+
|
|
86
|
+
def to_signable_dict(self) -> dict:
|
|
87
|
+
"""Returns envelope as dict excluding the integrity section.
|
|
88
|
+
This is what gets hashed before signing."""
|
|
89
|
+
d = self.model_dump(mode="json", exclude={"integrity"})
|
|
90
|
+
return d
|
octp/core/validator.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from octp.core.envelope import OCTPEnvelope
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_envelope_json(data: dict) -> bool:
|
|
10
|
+
"""Validate that a dict conforms to OCTP v0.1 envelope schema."""
|
|
11
|
+
try:
|
|
12
|
+
OCTPEnvelope.model_validate(data)
|
|
13
|
+
return True
|
|
14
|
+
except Exception:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_envelope_file(path: Path) -> tuple[bool, str]:
|
|
19
|
+
"""Validate an envelope file.
|
|
20
|
+
Returns (is_valid, error_message)."""
|
|
21
|
+
try:
|
|
22
|
+
data = json.loads(path.read_text())
|
|
23
|
+
OCTPEnvelope.model_validate(data)
|
|
24
|
+
return True, ""
|
|
25
|
+
except json.JSONDecodeError as e:
|
|
26
|
+
return False, f"Invalid JSON: {e}"
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return False, f"Schema validation failed: {e}"
|
octp/git/__init__.py
ADDED
|
File without changes
|
octp/git/reader.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import git
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class RepoInfo:
|
|
12
|
+
commit_hash: str
|
|
13
|
+
repository: str
|
|
14
|
+
branch: str
|
|
15
|
+
root: Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_repo(path: Path = Path(".")) -> RepoInfo:
|
|
19
|
+
"""Read current git repository state."""
|
|
20
|
+
try:
|
|
21
|
+
repo = git.Repo(path, search_parent_directories=True)
|
|
22
|
+
except git.InvalidGitRepositoryError:
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"Not inside a git repository. Run octp from within a git project."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
commit_hash = repo.head.commit.hexsha
|
|
28
|
+
|
|
29
|
+
# Normalise remote URL to platform/org/repo format
|
|
30
|
+
repository = _parse_remote(repo)
|
|
31
|
+
|
|
32
|
+
branch = repo.active_branch.name if not repo.head.is_detached else "detached"
|
|
33
|
+
root = Path(repo.working_dir)
|
|
34
|
+
|
|
35
|
+
return RepoInfo(
|
|
36
|
+
commit_hash=commit_hash,
|
|
37
|
+
repository=repository,
|
|
38
|
+
branch=branch,
|
|
39
|
+
root=root,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_remote(repo: git.Repo) -> str:
|
|
44
|
+
"""Extract platform/org/repo from remote URL."""
|
|
45
|
+
try:
|
|
46
|
+
remote_url = repo.remotes.origin.url
|
|
47
|
+
except (AttributeError, IndexError):
|
|
48
|
+
return "unknown/unknown/unknown"
|
|
49
|
+
|
|
50
|
+
# Handle SSH: git@github.com:org/repo.git
|
|
51
|
+
ssh = re.match(r"git@([^:]+):(.+?)(?:\.git)?$", remote_url)
|
|
52
|
+
if ssh:
|
|
53
|
+
host = ssh.group(1)
|
|
54
|
+
path = ssh.group(2)
|
|
55
|
+
return f"{host}/{path}"
|
|
56
|
+
|
|
57
|
+
# Handle HTTPS: https://github.com/org/repo.git
|
|
58
|
+
https = re.match(r"https?://([^/]+)/(.+?)(?:\.git)?$", remote_url)
|
|
59
|
+
if https:
|
|
60
|
+
host = https.group(1)
|
|
61
|
+
path = https.group(2)
|
|
62
|
+
return f"{host}/{path}"
|
|
63
|
+
|
|
64
|
+
return remote_url
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
8
|
+
|
|
9
|
+
KEYS_DIR = Path.home() / ".octp" / "keys"
|
|
10
|
+
PRIVATE_KEY_FILE = KEYS_DIR / "private.pem"
|
|
11
|
+
PUBLIC_KEY_FILE = KEYS_DIR / "public.pem"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_keypair() -> None:
|
|
15
|
+
"""Generate keypair if it doesn't exist."""
|
|
16
|
+
if PRIVATE_KEY_FILE.exists():
|
|
17
|
+
return
|
|
18
|
+
KEYS_DIR.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
private_key = ec.generate_private_key(ec.SECP256R1())
|
|
20
|
+
public_key = private_key.public_key()
|
|
21
|
+
|
|
22
|
+
with open(PRIVATE_KEY_FILE, "wb") as f:
|
|
23
|
+
f.write(
|
|
24
|
+
private_key.private_bytes(
|
|
25
|
+
encoding=serialization.Encoding.PEM,
|
|
26
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
27
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
PRIVATE_KEY_FILE.chmod(0o600)
|
|
31
|
+
|
|
32
|
+
with open(PUBLIC_KEY_FILE, "wb") as f:
|
|
33
|
+
f.write(
|
|
34
|
+
public_key.public_bytes(
|
|
35
|
+
encoding=serialization.Encoding.PEM,
|
|
36
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sign_payload(payload_hash: str) -> str:
|
|
42
|
+
"""Sign a payload hash with the developer's private key.
|
|
43
|
+
Returns base64-encoded signature."""
|
|
44
|
+
ensure_keypair()
|
|
45
|
+
with open(PRIVATE_KEY_FILE, "rb") as f:
|
|
46
|
+
private_key = serialization.load_pem_private_key(f.read(), password=None)
|
|
47
|
+
|
|
48
|
+
signature = private_key.sign(
|
|
49
|
+
payload_hash.encode(),
|
|
50
|
+
ec.ECDSA(hashes.SHA256()),
|
|
51
|
+
)
|
|
52
|
+
return base64.b64encode(signature).decode()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_public_key_pem() -> str:
|
|
56
|
+
"""Return the developer's public key in PEM format."""
|
|
57
|
+
ensure_keypair()
|
|
58
|
+
return PUBLIC_KEY_FILE.read_text()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def verify_signature(
|
|
62
|
+
payload_hash: str, signature_b64: str, public_key_pem: str
|
|
63
|
+
) -> bool:
|
|
64
|
+
"""Verify a signature against a payload hash and public key."""
|
|
65
|
+
try:
|
|
66
|
+
public_key = serialization.load_pem_public_key(public_key_pem.encode())
|
|
67
|
+
signature = base64.b64decode(signature_b64)
|
|
68
|
+
public_key.verify(
|
|
69
|
+
signature,
|
|
70
|
+
payload_hash.encode(),
|
|
71
|
+
ec.ECDSA(hashes.SHA256()),
|
|
72
|
+
)
|
|
73
|
+
return True
|
|
74
|
+
except Exception:
|
|
75
|
+
return False
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import git
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_developer_id(repo_path: Path = Path(".")) -> str:
|
|
9
|
+
"""Resolve developer identity from git config."""
|
|
10
|
+
try:
|
|
11
|
+
repo = git.Repo(repo_path, search_parent_directories=True)
|
|
12
|
+
config = repo.config_reader()
|
|
13
|
+
|
|
14
|
+
# Try to get GitHub username from git config
|
|
15
|
+
try:
|
|
16
|
+
github_user = config.get_value("github", "user", default=None)
|
|
17
|
+
if github_user:
|
|
18
|
+
return f"github:{github_user}"
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
# Fall back to git user email
|
|
23
|
+
try:
|
|
24
|
+
email = config.get_value("user", "email", default=None)
|
|
25
|
+
if email:
|
|
26
|
+
return f"email:{email}"
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
# Fall back to git user name
|
|
31
|
+
try:
|
|
32
|
+
name = config.get_value("user", "name", default=None)
|
|
33
|
+
if name:
|
|
34
|
+
return f"git:{name}"
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
return "unknown"
|
|
File without changes
|