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 +8 -0
- codevoight/cli.py +219 -0
- codevoight/core/config.py +51 -0
- codevoight/core/diff.py +51 -0
- codevoight/core/models.py +82 -0
- codevoight/core/security.py +68 -0
- codevoight/db/repository.py +221 -0
- codevoight/db/schema.py +40 -0
- codevoight/generators/base.py +20 -0
- codevoight/generators/deterministic.py +216 -0
- codevoight/generators/llm.py +33 -0
- codevoight/git/hook.py +113 -0
- codevoight/git/runner.py +57 -0
- codevoight/server.py +209 -0
- codevoight-0.1.0.dist-info/METADATA +191 -0
- codevoight-0.1.0.dist-info/RECORD +20 -0
- codevoight-0.1.0.dist-info/WHEEL +5 -0
- codevoight-0.1.0.dist-info/entry_points.txt +2 -0
- codevoight-0.1.0.dist-info/licenses/LICENSE +21 -0
- codevoight-0.1.0.dist-info/top_level.txt +1 -0
codevoight/__init__.py
ADDED
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
|
codevoight/core/diff.py
ADDED
|
@@ -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)
|