codevoight 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.
codevoight/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """CodeVoight - Voight-Kampff Human Comprehension Gate for AI Commits."""
2
+
3
+ from codevoight.core.config import load_dotenv
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ # Auto-load .env if present in workspace root
8
+ load_dotenv()
codevoight/cli.py ADDED
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import click
9
+
10
+ from codevoight.core.diff import compute_diff_hash, normalize_text
11
+ from codevoight.core.models import (
12
+ QuizSubmission,
13
+ SessionStatus,
14
+ StagedSession,
15
+ )
16
+ from codevoight.core.security import get_or_create_secret, sign_token
17
+ from codevoight.db.repository import StateRepository
18
+ from codevoight.generators.deterministic import (
19
+ DeterministicQuizEvaluator,
20
+ DeterministicQuizGenerator,
21
+ )
22
+ from codevoight.git.hook import install_pre_commit_hook, verify_staged_commit
23
+ from codevoight.git.runner import GitRunner
24
+
25
+
26
+ def get_repo_and_db_paths() -> tuple[Path, Path]:
27
+ runner = GitRunner()
28
+ root = (runner.get_repo_root() or Path.cwd()).resolve()
29
+ codevoight_dir = root / ".codevoight"
30
+ if not codevoight_dir.exists():
31
+ if (root / ".quizgate").exists():
32
+ codevoight_dir = root / ".quizgate"
33
+ elif (root / ".codegate").exists():
34
+ codevoight_dir = root / ".codegate"
35
+ db_path = codevoight_dir / "state.db"
36
+ return codevoight_dir, db_path
37
+
38
+
39
+ @click.group()
40
+ def main() -> None:
41
+ """CodeVoight: Voight-Kampff Comprehension Gate for AI Commits."""
42
+ pass
43
+
44
+
45
+ @main.command("quiz")
46
+ @click.argument("session_id", required=False)
47
+ def run_quiz(session_id: Optional[str] = None) -> None:
48
+ """Take comprehension quiz for a staged change to unlock git commit."""
49
+ codevoight_dir, db_path = get_repo_and_db_paths()
50
+ repo = StateRepository(db_path)
51
+
52
+ session = None
53
+ if session_id:
54
+ session = repo.get_session(session_id)
55
+ if not session:
56
+ click.echo(f"[ERROR] Session '{session_id}' not found.", err=True)
57
+ sys.exit(1)
58
+ else:
59
+ session = repo.get_latest_pending_session()
60
+ if not session:
61
+ click.echo("[INFO] No pending quiz sessions found.", err=True)
62
+ click.echo("Stage code changes via MCP or 'codevoight stage' first.")
63
+ sys.exit(0)
64
+
65
+ click.echo("=" * 60)
66
+ click.echo(f"CODEVOIGHT COMPREHENSION QUIZ")
67
+ click.echo(f"Session ID : {session.session_id}")
68
+ click.echo(f"Diff Hash : {session.diff_hash[:16]}...")
69
+ if session.description:
70
+ click.echo(f"Description: {session.description}")
71
+ click.echo("=" * 60)
72
+
73
+ # Show Diff Summary
74
+ click.echo("\n--- STAGED DIFF PREVIEW ---")
75
+ diff_lines = session.diff_text.strip().splitlines()
76
+ for line in diff_lines[:30]:
77
+ click.echo(line)
78
+ if len(diff_lines) > 30:
79
+ click.echo(f"... ({len(diff_lines) - 30} more lines omitted) ...")
80
+ click.echo("---------------------------\n")
81
+
82
+ questions = repo.get_questions_for_session(session.session_id)
83
+ if not questions:
84
+ click.echo("[WARNING] No questions found for this session. Generating on the fly...")
85
+ gen = DeterministicQuizGenerator()
86
+ questions = gen.generate_quiz(session.diff_text, session.description)
87
+
88
+ answers = {}
89
+ for idx, q in enumerate(questions, 1):
90
+ click.echo(f"\n[Question {idx}/{len(questions)}] {q.prompt}")
91
+ for opt in q.options:
92
+ click.echo(f" [{opt.id}] {opt.text}")
93
+
94
+ valid_ids = [opt.id.upper() for opt in q.options]
95
+ chosen = ""
96
+ while chosen not in valid_ids:
97
+ chosen = click.prompt("Your answer", type=str).strip().upper()
98
+ answers[q.id] = chosen
99
+
100
+ # Evaluate
101
+ submission = QuizSubmission(session_id=session.session_id, answers=answers)
102
+ evaluator = DeterministicQuizEvaluator(passing_score=1.0)
103
+ result = evaluator.evaluate(submission, questions)
104
+
105
+ click.echo("\n" + "=" * 60)
106
+ if result.passed:
107
+ secret_key = get_or_create_secret(codevoight_dir)
108
+ token = sign_token(session.session_id, session.diff_hash, secret_key)
109
+ repo.update_session_status(session.session_id, SessionStatus.APPROVED, token.serialize())
110
+
111
+ click.echo(f"[PASSED] Score: {int(result.score * 100)}% ({result.correct}/{result.total})")
112
+ click.echo(f"[UNLOCKED] Capability token generated for diff hash {session.diff_hash[:16]}.")
113
+ click.echo("You may now proceed with 'git commit' or call the MCP commit tool.")
114
+ else:
115
+ repo.update_session_status(session.session_id, SessionStatus.REJECTED)
116
+ click.echo(f"[FAILED] Score: {int(result.score * 100)}% ({result.correct}/{result.total})")
117
+ click.echo("[LOCKED] Commit permission DENIED. Review the following feedback:")
118
+ for fb in result.feedback:
119
+ click.echo(f" * {fb}")
120
+ click.echo("\nReview the diff and run 'codevoight quiz' to try again.")
121
+ sys.exit(1)
122
+ click.echo("=" * 60)
123
+
124
+
125
+ @main.command("status")
126
+ def show_status() -> None:
127
+ """View active and recent CodeVoight staging sessions."""
128
+ _, db_path = get_repo_and_db_paths()
129
+ if not db_path.exists():
130
+ click.echo("[INFO] No CodeVoight database found in current repository.")
131
+ return
132
+
133
+ repo = StateRepository(db_path)
134
+ sessions = repo.list_sessions(limit=10)
135
+ if not sessions:
136
+ click.echo("[INFO] No staging sessions found.")
137
+ return
138
+
139
+ click.echo(f"{'SESSION ID':<12} {'STATUS':<15} {'EXPIRES IN':<12} {'DIFF HASH':<16} {'DESCRIPTION'}")
140
+ click.echo("-" * 75)
141
+ now = time.time()
142
+ for s in sessions:
143
+ remaining = max(0, int(s.expires_at - now))
144
+ rem_str = f"{remaining}s" if remaining > 0 else "EXPIRED"
145
+ click.echo(f"{s.session_id[:10]:<12} {s.status.value:<15} {rem_str:<12} {s.diff_hash[:14]:<16} {s.description[:20]}")
146
+
147
+
148
+ @main.command("hook")
149
+ @click.argument("action", type=click.Choice(["install", "verify"]))
150
+ def manage_hook(action: str) -> None:
151
+ """Install or test the Git pre-commit verification hook."""
152
+ if action == "install":
153
+ success, msg = install_pre_commit_hook()
154
+ if success:
155
+ click.echo(f"[SUCCESS] CodeVoight pre-commit hook installed to: {msg}")
156
+ else:
157
+ click.echo(f"[ERROR] Failed to install hook: {msg}", err=True)
158
+ sys.exit(1)
159
+ elif action == "verify":
160
+ success, msg = verify_staged_commit()
161
+ if success:
162
+ click.echo(f"[SUCCESS] {msg}")
163
+ else:
164
+ click.echo(f"[DENIED] {msg}", err=True)
165
+ sys.exit(1)
166
+
167
+
168
+ @main.command("verify-commit", hidden=True)
169
+ def run_verify_commit() -> None:
170
+ """Internal entrypoint invoked directly by .git/hooks/pre-commit."""
171
+ success, msg = verify_staged_commit()
172
+ if not success:
173
+ click.echo(f"[CODEVOIGHT] {msg}", err=True)
174
+ sys.exit(1)
175
+ sys.exit(0)
176
+
177
+
178
+ @main.command("stage")
179
+ @click.argument("files", nargs=-1, required=True)
180
+ @click.option("--description", "-m", default="Manual CLI staging", help="Description of staged change")
181
+ def stage_from_cli(files: tuple[str, ...], description: str) -> None:
182
+ """Stage files and create a comprehension quiz session."""
183
+ runner = GitRunner()
184
+ if not runner.is_git_repo():
185
+ click.echo("[ERROR] Not in a git repository.", err=True)
186
+ sys.exit(1)
187
+
188
+ runner.stage_files(list(files))
189
+ staged_diff = runner.get_staged_diff()
190
+ if not staged_diff.strip():
191
+ click.echo("[WARNING] No staged changes found for specified files.")
192
+ return
193
+
194
+ import uuid
195
+ codevoight_dir, db_path = get_repo_and_db_paths()
196
+ repo = StateRepository(db_path)
197
+ diff_hash = compute_diff_hash(staged_diff)
198
+
199
+ gen = DeterministicQuizGenerator()
200
+ questions = gen.generate_quiz(staged_diff, description)
201
+
202
+ now = time.time()
203
+ session = StagedSession(
204
+ session_id=str(uuid.uuid4())[:8],
205
+ diff_text=staged_diff,
206
+ diff_hash=diff_hash,
207
+ description=description,
208
+ status=SessionStatus.PENDING_QUIZ,
209
+ created_at=now,
210
+ expires_at=now + 1800.0,
211
+ )
212
+
213
+ repo.create_session(session, questions)
214
+ click.echo(f"[SUCCESS] Staged session created: {session.session_id}")
215
+ click.echo(f"Run 'codevoight quiz {session.session_id}' to take the quiz and unlock commit.")
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ def load_dotenv(env_path: Optional[Path] = None) -> None:
9
+ """Load key-value pairs from .env into os.environ without overriding existing keys."""
10
+ target = env_path or (Path.cwd() / ".env")
11
+ if not target.exists():
12
+ return
13
+
14
+ try:
15
+ content = target.read_text(encoding="utf-8")
16
+ except Exception:
17
+ return
18
+
19
+ for line in content.splitlines():
20
+ line = line.strip()
21
+ if not line or line.startswith("#") or "=" not in line:
22
+ continue
23
+ key, val = line.split("=", 1)
24
+ key = key.strip()
25
+ val = val.strip().strip("\"'")
26
+ if key and key not in os.environ:
27
+ os.environ[key] = val
28
+
29
+
30
+ def get_token_ttl() -> float:
31
+ """Return capability token TTL in seconds (default: 1800.0)."""
32
+ val = os.getenv("CODEVOIGHT_TOKEN_TTL") or os.getenv("QUIZGATE_TOKEN_TTL") or os.getenv("CODEGATE_TOKEN_TTL")
33
+ if val:
34
+ try:
35
+ return float(val)
36
+ except ValueError:
37
+ pass
38
+ return 1800.0
39
+
40
+
41
+ def get_passing_score() -> float:
42
+ """Return required quiz passing score between 0.0 and 1.0 (default: 1.0)."""
43
+ val = os.getenv("CODEVOIGHT_PASSING_SCORE") or os.getenv("QUIZGATE_PASSING_SCORE") or os.getenv("CODEGATE_PASSING_SCORE")
44
+ if val:
45
+ try:
46
+ score = float(val)
47
+ if 0.0 <= score <= 1.0:
48
+ return score
49
+ except ValueError:
50
+ pass
51
+ return 1.0
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import hashlib
5
+ from typing import List, Optional
6
+
7
+ from codevoight.core.models import StagedFile
8
+
9
+
10
+ def normalize_text(text: Optional[str]) -> str:
11
+ """Normalize line endings to LF and strip trailing empty lines."""
12
+ if text is None:
13
+ return ""
14
+ normalized = text.replace("\r\n", "\n").replace("\r", "\n")
15
+ return normalized
16
+
17
+
18
+ def generate_file_diff(file_path: str, old_content: Optional[str], new_content: str) -> str:
19
+ """Generate a unified diff string for a single file."""
20
+ norm_old = normalize_text(old_content).splitlines(keepends=True)
21
+ norm_new = normalize_text(new_content).splitlines(keepends=True)
22
+
23
+ from_file = f"a/{file_path}" if old_content is not None else "/dev/null"
24
+ to_file = f"b/{file_path}"
25
+
26
+ diff_lines = list(
27
+ difflib.unified_diff(
28
+ norm_old,
29
+ norm_new,
30
+ fromfile=from_file,
31
+ tofile=to_file,
32
+ lineterm="\n",
33
+ )
34
+ )
35
+ return "".join(diff_lines)
36
+
37
+
38
+ def generate_diff(files: List[StagedFile]) -> str:
39
+ """Generate combined unified diff across multiple staged files."""
40
+ chunks = []
41
+ for f in sorted(files, key=lambda x: x.file_path):
42
+ d = generate_file_diff(f.file_path, f.old_content, f.new_content)
43
+ if d:
44
+ chunks.append(d)
45
+ return "\n".join(chunks)
46
+
47
+
48
+ def compute_diff_hash(diff_text: str) -> str:
49
+ """Compute deterministic SHA-256 hash of normalized unified diff."""
50
+ norm = normalize_text(diff_text).strip()
51
+ return hashlib.sha256(norm.encode("utf-8")).hexdigest()
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import List, Optional
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class SessionStatus(str, Enum):
9
+ PENDING_QUIZ = "PENDING_QUIZ"
10
+ APPROVED = "APPROVED"
11
+ REJECTED = "REJECTED"
12
+ EXPIRED = "EXPIRED"
13
+ CONSUMED = "CONSUMED"
14
+
15
+
16
+ class StagedFile(BaseModel):
17
+ file_path: str
18
+ new_content: str
19
+ old_content: Optional[str] = None
20
+ is_new: bool = False
21
+ is_deleted: bool = False
22
+
23
+
24
+ class QuizOption(BaseModel):
25
+ id: str # "A", "B", "C", "D"
26
+ text: str
27
+
28
+
29
+ class QuizQuestion(BaseModel):
30
+ id: str
31
+ prompt: str
32
+ options: List[QuizOption]
33
+ correct_option_id: str
34
+ explanation: str
35
+
36
+
37
+ class QuizSubmission(BaseModel):
38
+ session_id: str
39
+ answers: dict[str, str] # question_id -> chosen_option_id
40
+
41
+
42
+ class QuizResult(BaseModel):
43
+ session_id: str
44
+ passed: bool
45
+ score: float
46
+ total: int
47
+ correct: int
48
+ feedback: List[str] = Field(default_factory=list)
49
+ capability_token: Optional[str] = None
50
+
51
+
52
+ class CapabilityToken(BaseModel):
53
+ session_id: str
54
+ diff_hash: str
55
+ timestamp: int
56
+ signature: str
57
+
58
+ def serialize(self) -> str:
59
+ return f"{self.session_id}:{self.diff_hash}:{self.timestamp}:{self.signature}"
60
+
61
+ @classmethod
62
+ def parse(cls, raw: str) -> CapabilityToken:
63
+ parts = raw.split(":")
64
+ if len(parts) != 4:
65
+ raise ValueError("Malformed capability token format")
66
+ return cls(
67
+ session_id=parts[0],
68
+ diff_hash=parts[1],
69
+ timestamp=int(parts[2]),
70
+ signature=parts[3],
71
+ )
72
+
73
+
74
+ class StagedSession(BaseModel):
75
+ session_id: str
76
+ diff_text: str
77
+ diff_hash: str
78
+ description: str = ""
79
+ status: SessionStatus = SessionStatus.PENDING_QUIZ
80
+ created_at: float
81
+ expires_at: float
82
+ capability_token: Optional[str] = None
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ import hashlib
5
+ import os
6
+ import secrets
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from codevoight.core.models import CapabilityToken
12
+
13
+
14
+ DEFAULT_TTL_SECONDS = 1800.0 # 30 minutes
15
+
16
+
17
+ def get_or_create_secret(secret_dir: Path) -> bytes:
18
+ """Retrieve or generate a 32-byte cryptographic secret stored in secret_dir/.secret."""
19
+ secret_dir.mkdir(parents=True, exist_ok=True)
20
+ secret_path = secret_dir / ".secret"
21
+
22
+ if secret_path.exists():
23
+ secret_bytes = secret_path.read_bytes().strip()
24
+ if len(secret_bytes) >= 32:
25
+ return secret_bytes
26
+
27
+ new_secret = secrets.token_bytes(32)
28
+ # Write with restricted permissions
29
+ secret_path.write_bytes(new_secret)
30
+ return new_secret
31
+
32
+
33
+ def sign_token(session_id: str, diff_hash: str, secret_key: bytes, timestamp: Optional[int] = None) -> CapabilityToken:
34
+ """Generate an HMAC-SHA256 capability token for a verified session diff."""
35
+ ts = int(timestamp) if timestamp is not None else int(time.time())
36
+ payload = f"{session_id}:{diff_hash}:{ts}".encode("utf-8")
37
+ sig = hmac.new(secret_key, payload, hashlib.sha256).hexdigest()
38
+ return CapabilityToken(
39
+ session_id=session_id,
40
+ diff_hash=diff_hash,
41
+ timestamp=ts,
42
+ signature=sig,
43
+ )
44
+
45
+
46
+ def verify_token(
47
+ token: CapabilityToken,
48
+ expected_diff_hash: str,
49
+ secret_key: bytes,
50
+ ttl_seconds: float = DEFAULT_TTL_SECONDS,
51
+ current_time: Optional[float] = None,
52
+ ) -> bool:
53
+ """Verify validity, expiration, and cryptographic signature of a capability token."""
54
+ now = current_time if current_time is not None else time.time()
55
+
56
+ # Expiry check
57
+ if now - token.timestamp > ttl_seconds or token.timestamp > now + 60.0:
58
+ return False
59
+
60
+ # Diff hash match check (constant time)
61
+ if not hmac.compare_digest(token.diff_hash, expected_diff_hash):
62
+ return False
63
+
64
+ # Signature recomputation
65
+ payload = f"{token.session_id}:{token.diff_hash}:{token.timestamp}".encode("utf-8")
66
+ expected_sig = hmac.new(secret_key, payload, hashlib.sha256).hexdigest()
67
+
68
+ return hmac.compare_digest(token.signature, expected_sig)