repoward 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.
repoward/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """repoward: a GitHub MCP server with enforced guardrails for AI agents."""
@@ -0,0 +1 @@
1
+ """The only code allowed to talk to the network."""
@@ -0,0 +1,50 @@
1
+ """Job log download: the one place a redirect is followed, under strict rules."""
2
+
3
+ import re
4
+ from urllib.parse import urlsplit
5
+
6
+ import httpx
7
+
8
+ from repoward.adapters.github import TIMEOUT_SECONDS, USER_AGENT, GitHubClient, repo_path
9
+ from repoward.registry import ToolError
10
+
11
+ # Observed from a real job-log redirect. An unknown host fails closed; update this if GitHub
12
+ # moves its log storage.
13
+ _LOG_HOST = re.compile(r"productionresultssa\d+\.blob\.core\.windows\.net")
14
+ MAX_LOG_DOWNLOAD_BYTES = 20_000_000
15
+ LOG_TAIL_BYTES = 200_000
16
+
17
+
18
+ async def fetch_job_log_tail(client: GitHubClient, repo: str, job_id: int) -> tuple[str, bool]:
19
+ """Return the end of a job's log, where failures are, and whether anything was cut."""
20
+ location = await client.get_redirect_location(f"{repo_path(repo)}/actions/jobs/{job_id}/logs")
21
+ parts = urlsplit(location)
22
+ if parts.scheme != "https" or not _LOG_HOST.fullmatch(parts.hostname or ""):
23
+ raise ToolError("GitHub redirected the log download to an unrecognized host.")
24
+
25
+ tail = bytearray()
26
+ total = 0
27
+ cut = False
28
+ # A separate client, so the GitHub token can never be sent to the storage host.
29
+ try:
30
+ async with (
31
+ httpx.AsyncClient(
32
+ follow_redirects=False,
33
+ timeout=TIMEOUT_SECONDS,
34
+ headers={"User-Agent": USER_AGENT},
35
+ ) as http,
36
+ http.stream("GET", location) as response,
37
+ ):
38
+ if response.status_code != 200:
39
+ raise ToolError("The job log could not be downloaded.")
40
+ async for chunk in response.aiter_bytes():
41
+ total += len(chunk)
42
+ tail += chunk
43
+ if len(tail) > LOG_TAIL_BYTES:
44
+ del tail[: len(tail) - LOG_TAIL_BYTES]
45
+ cut = True
46
+ if total > MAX_LOG_DOWNLOAD_BYTES:
47
+ raise ToolError("The job log is too large to download.")
48
+ except httpx.HTTPError as exc:
49
+ raise ToolError("The job log could not be downloaded.") from exc
50
+ return tail.decode("utf-8", "replace"), cut
@@ -0,0 +1,150 @@
1
+ """GitHub REST client. No tool input ever becomes a URL host; redirects are never followed."""
2
+
3
+ import json
4
+ import time
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ import httpx
9
+
10
+ from repoward.registry import ToolError
11
+
12
+ BASE_URL = "https://api.github.com"
13
+ API_VERSION = "2026-03-10"
14
+ USER_AGENT = "repoward"
15
+ TIMEOUT_SECONDS = 30.0
16
+ MAX_RESPONSE_BYTES = 5_000_000
17
+ JSON_ACCEPT = "application/vnd.github+json"
18
+ TEXT_MATCH_ACCEPT = "application/vnd.github.text-match+json"
19
+
20
+
21
+ def repo_path(repo: str) -> str:
22
+ owner, name = repo.split("/", 1)
23
+ return f"/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
24
+
25
+
26
+ def file_url_path(path: str) -> str:
27
+ return "/".join(quote(segment, safe="") for segment in path.split("/"))
28
+
29
+
30
+ def _wait_seconds(response: httpx.Response) -> int | None:
31
+ """How long GitHub asks us to wait, if this response is a rate limit."""
32
+ retry_after = response.headers.get("retry-after", "")
33
+ if retry_after.isdigit():
34
+ return int(retry_after)
35
+ if response.headers.get("x-ratelimit-remaining") == "0":
36
+ reset = response.headers.get("x-ratelimit-reset", "")
37
+ return max(1, int(reset) - int(time.time())) if reset.isdigit() else 60
38
+ return None
39
+
40
+
41
+ def raise_for_status(response: httpx.Response) -> None:
42
+ """Turn a failed response into a safe message. GitHub's own text is never passed on."""
43
+ status = response.status_code
44
+ if 200 <= status < 300:
45
+ return
46
+ if status in {403, 429}:
47
+ wait = _wait_seconds(response)
48
+ if wait is not None:
49
+ raise ToolError(f"GitHub rate limit reached. Try again in {wait} seconds.")
50
+ if status == 403:
51
+ raise ToolError("GitHub refused the request; the token may lack permission.")
52
+ if status == 401:
53
+ raise ToolError("GitHub rejected the token; it may be invalid or expired.")
54
+ if status == 404:
55
+ raise ToolError("Not found, or the token cannot access it.")
56
+ if status in {301, 302, 307, 308}:
57
+ raise ToolError("GitHub answered with a redirect, which is not followed.")
58
+ if status == 405:
59
+ raise ToolError("GitHub says this cannot be done in the current state.")
60
+ if status == 409:
61
+ raise ToolError("GitHub reported a conflict; the branch may have moved.")
62
+ if status == 422:
63
+ raise ToolError("GitHub rejected the request as invalid.")
64
+ raise ToolError(f"GitHub returned an unexpected status ({status}).")
65
+
66
+
67
+ class GitHubClient:
68
+ def __init__(self, token: str | None) -> None:
69
+ self._token = token
70
+ self._http = httpx.AsyncClient(
71
+ base_url=BASE_URL,
72
+ follow_redirects=False,
73
+ timeout=TIMEOUT_SECONDS,
74
+ headers={
75
+ "Accept": JSON_ACCEPT,
76
+ "X-GitHub-Api-Version": API_VERSION,
77
+ "User-Agent": USER_AGENT,
78
+ },
79
+ )
80
+
81
+ def _auth(self) -> dict[str, str]:
82
+ if not self._token:
83
+ raise ToolError("GITHUB_TOKEN is not set on the server.")
84
+ return {"Authorization": f"Bearer {self._token}"}
85
+
86
+ async def aclose(self) -> None:
87
+ await self._http.aclose()
88
+
89
+ async def get_json(
90
+ self, path: str, params: dict[str, Any] | None = None, accept: str = JSON_ACCEPT
91
+ ) -> Any:
92
+ """GET a JSON document."""
93
+ return await self._request("GET", path, params, None, accept)
94
+
95
+ async def send_json(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
96
+ """Send a change once. Writes are never retried, so a failure is never doubled."""
97
+ return await self._request(method, path, None, body, JSON_ACCEPT)
98
+
99
+ async def _request(
100
+ self,
101
+ method: str,
102
+ path: str,
103
+ params: dict[str, Any] | None,
104
+ body: dict[str, Any] | None,
105
+ accept: str,
106
+ ) -> Any:
107
+ """Make one request, refusing response bodies over the size cap."""
108
+ headers = {**self._auth(), "Accept": accept}
109
+ try:
110
+ async with self._http.stream(
111
+ method, path, params=params, json=body, headers=headers
112
+ ) as response:
113
+ raise_for_status(response)
114
+ chunks: list[bytes] = []
115
+ size = 0
116
+ async for chunk in response.aiter_bytes():
117
+ size += len(chunk)
118
+ if size > MAX_RESPONSE_BYTES:
119
+ raise ToolError("GitHub's response was too large to process.")
120
+ chunks.append(chunk)
121
+ except httpx.HTTPError as exc:
122
+ raise ToolError("Could not reach GitHub.") from exc
123
+ if not chunks:
124
+ return None
125
+ try:
126
+ return json.loads(b"".join(chunks))
127
+ except ValueError as exc:
128
+ raise ToolError("GitHub returned a response that could not be read.") from exc
129
+
130
+ async def repo_is_private(self, repo: str) -> bool:
131
+ """Whether a repository is private. Raises if GitHub gives no clear answer."""
132
+ document = await self.get_json(repo_path(repo))
133
+ private = document.get("private") if isinstance(document, dict) else None
134
+ if not isinstance(private, bool):
135
+ raise ToolError("GitHub returned an unexpected response.")
136
+ return private
137
+
138
+ async def get_redirect_location(self, path: str) -> str:
139
+ """Request an endpoint that answers 302, returning where it points."""
140
+ try:
141
+ response = await self._http.get(path, headers=self._auth())
142
+ except httpx.HTTPError as exc:
143
+ raise ToolError("Could not reach GitHub.") from exc
144
+ if response.status_code != 302:
145
+ raise_for_status(response)
146
+ raise ToolError("GitHub did not return the expected redirect.")
147
+ location = response.headers.get("location")
148
+ if not location:
149
+ raise ToolError("GitHub's redirect had no destination.")
150
+ return str(location)
@@ -0,0 +1 @@
1
+ """Human approval for risky tool calls."""
@@ -0,0 +1,199 @@
1
+ """SQLite approval store. Each operation is one short transaction, so processes can share it."""
2
+
3
+ import secrets
4
+ import sqlite3
5
+ import time
6
+ from collections.abc import Callable
7
+ from contextlib import AbstractContextManager
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from repoward.storage import transaction
12
+
13
+ _SCHEMA = """
14
+ CREATE TABLE IF NOT EXISTS approvals (
15
+ id TEXT PRIMARY KEY,
16
+ ts REAL NOT NULL,
17
+ principal TEXT NOT NULL,
18
+ tool TEXT NOT NULL,
19
+ repo TEXT,
20
+ args_hash TEXT NOT NULL,
21
+ args_redacted TEXT NOT NULL,
22
+ context TEXT NOT NULL,
23
+ status TEXT NOT NULL CHECK (status IN ('pending','approved','denied','expired','consumed')),
24
+ decided_by TEXT,
25
+ decided_at REAL,
26
+ expires_at REAL NOT NULL,
27
+ consumed_at REAL
28
+ )
29
+ """
30
+ _COLUMNS = (
31
+ "id, ts, principal, tool, repo, args_hash, args_redacted, context, status, "
32
+ "decided_by, decided_at, expires_at, consumed_at"
33
+ )
34
+
35
+
36
+ class ApprovalStoreError(Exception):
37
+ """The store could not complete an operation. Callers must not execute anything."""
38
+
39
+
40
+ class TooManyPendingError(ApprovalStoreError):
41
+ """This principal already has the maximum number of pending requests."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Approval:
46
+ id: str
47
+ ts: float
48
+ principal: str
49
+ tool: str
50
+ repo: str | None
51
+ args_hash: str
52
+ args_redacted: str
53
+ context: str
54
+ status: str
55
+ decided_by: str | None
56
+ decided_at: float | None
57
+ expires_at: float
58
+ consumed_at: float | None
59
+
60
+
61
+ class ApprovalStore:
62
+ def __init__(
63
+ self,
64
+ path: Path,
65
+ ttl_seconds: float = 900.0,
66
+ max_pending_per_principal: int = 20,
67
+ clock: Callable[[], float] = time.time,
68
+ ) -> None:
69
+ self._path = path
70
+ self._ttl = ttl_seconds
71
+ self._max_pending = max_pending_per_principal
72
+ self._clock = clock
73
+ with self._transaction() as connection:
74
+ connection.execute(_SCHEMA)
75
+
76
+ def _transaction(self) -> AbstractContextManager[sqlite3.Connection]:
77
+ return transaction(self._path, ApprovalStoreError)
78
+
79
+ def _expire(self, connection: sqlite3.Connection, now: float) -> None:
80
+ connection.execute(
81
+ "UPDATE approvals SET status = 'expired' "
82
+ "WHERE status IN ('pending', 'approved') AND expires_at <= ?",
83
+ (now,),
84
+ )
85
+
86
+ def request(
87
+ self,
88
+ principal: str,
89
+ tool: str,
90
+ repo: str | None,
91
+ args_hash: str,
92
+ args_redacted: str,
93
+ context: str,
94
+ ) -> Approval:
95
+ """Create a pending request, or return the identical one already waiting."""
96
+ now = self._clock()
97
+ with self._transaction() as connection:
98
+ self._expire(connection, now)
99
+ row = connection.execute(
100
+ f"SELECT {_COLUMNS} FROM approvals WHERE principal = ? AND tool = ? " # noqa: S608
101
+ "AND repo IS ? AND args_hash = ? AND status = 'pending'",
102
+ (principal, tool, repo, args_hash),
103
+ ).fetchone()
104
+ if row is not None:
105
+ return Approval(*row)
106
+ pending = connection.execute(
107
+ "SELECT COUNT(*) FROM approvals WHERE principal = ? AND status = 'pending'",
108
+ (principal,),
109
+ ).fetchone()[0]
110
+ if pending >= self._max_pending:
111
+ raise TooManyPendingError("too many pending approval requests")
112
+ approval = Approval(
113
+ id=secrets.token_hex(16),
114
+ ts=now,
115
+ principal=principal,
116
+ tool=tool,
117
+ repo=repo,
118
+ args_hash=args_hash,
119
+ args_redacted=args_redacted,
120
+ context=context,
121
+ status="pending",
122
+ decided_by=None,
123
+ decided_at=None,
124
+ expires_at=now + self._ttl,
125
+ consumed_at=None,
126
+ )
127
+ connection.execute(
128
+ f"INSERT INTO approvals ({_COLUMNS}) " # noqa: S608
129
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
130
+ (
131
+ approval.id,
132
+ approval.ts,
133
+ approval.principal,
134
+ approval.tool,
135
+ approval.repo,
136
+ approval.args_hash,
137
+ approval.args_redacted,
138
+ approval.context,
139
+ approval.status,
140
+ approval.decided_by,
141
+ approval.decided_at,
142
+ approval.expires_at,
143
+ approval.consumed_at,
144
+ ),
145
+ )
146
+ return approval
147
+
148
+ def consume(self, principal: str, tool: str, repo: str | None, args_hash: str) -> str | None:
149
+ """Use up one matching approval, returning its id, or None if there is none.
150
+
151
+ Consuming happens before the action runs, so each approval allows at most one attempt.
152
+ """
153
+ now = self._clock()
154
+ with self._transaction() as connection:
155
+ self._expire(connection, now)
156
+ row = connection.execute(
157
+ "SELECT id FROM approvals WHERE principal = ? AND tool = ? AND repo IS ? "
158
+ "AND args_hash = ? AND status = 'approved' ORDER BY decided_at LIMIT 1",
159
+ (principal, tool, repo, args_hash),
160
+ ).fetchone()
161
+ if row is None:
162
+ return None
163
+ cursor = connection.execute(
164
+ "UPDATE approvals SET status = 'consumed', consumed_at = ? "
165
+ "WHERE id = ? AND status = 'approved'",
166
+ (now, row[0]),
167
+ )
168
+ return str(row[0]) if cursor.rowcount == 1 else None
169
+
170
+ def decide(self, approval_id: str, approved: bool, decided_by: str) -> bool:
171
+ """Approve or deny a pending request. An approval gets a fresh expiry window."""
172
+ now = self._clock()
173
+ status = "approved" if approved else "denied"
174
+ with self._transaction() as connection:
175
+ self._expire(connection, now)
176
+ cursor = connection.execute(
177
+ "UPDATE approvals SET status = ?, decided_by = ?, decided_at = ?, expires_at = ? "
178
+ "WHERE id = ? AND status = 'pending'",
179
+ (status, decided_by, now, now + self._ttl, approval_id),
180
+ )
181
+ return cursor.rowcount == 1
182
+
183
+ def get(self, approval_id: str) -> Approval | None:
184
+ with self._transaction() as connection:
185
+ self._expire(connection, self._clock())
186
+ row = connection.execute(
187
+ f"SELECT {_COLUMNS} FROM approvals WHERE id = ?", # noqa: S608
188
+ (approval_id,),
189
+ ).fetchone()
190
+ return Approval(*row) if row is not None else None
191
+
192
+ def list(self, include_all: bool = False) -> list[Approval]:
193
+ condition = "" if include_all else "WHERE status = 'pending' "
194
+ with self._transaction() as connection:
195
+ self._expire(connection, self._clock())
196
+ rows = connection.execute(
197
+ f"SELECT {_COLUMNS} FROM approvals {condition}ORDER BY ts" # noqa: S608
198
+ ).fetchall()
199
+ return [Approval(*row) for row in rows]
repoward/audit.py ADDED
@@ -0,0 +1,235 @@
1
+ """Append-only, hash-chained JSONL audit log.
2
+
3
+ The chain is tamper-evident, not tamper-proof. Anyone who can write the file can recompute a
4
+ plain SHA-256 chain, and no chain alone reveals deleted newest records. An HMAC key the
5
+ attacker cannot read covers the first case; off-host checkpoints cover the second.
6
+ """
7
+
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from filelock import FileLock, Timeout
18
+
19
+ from repoward.canonical import canonical_json
20
+
21
+ DEFAULT_KEY_ENV = "REPOWARD_AUDIT_HMAC_KEY"
22
+ GENESIS_HASH = "0" * 64
23
+ SCHEMA_VERSION = 1
24
+ _ALG_SHA256 = "sha256"
25
+ _ALG_HMAC = "hmac-sha256"
26
+ _LOCK_TIMEOUT_SECONDS = 5.0
27
+ _TAIL_CHUNK_BYTES = 65_536
28
+ _MIN_KEY_BYTES = 32
29
+
30
+
31
+ class AuditWriteError(Exception):
32
+ """A record could not be durably written."""
33
+
34
+
35
+ class AuditKeyError(Exception):
36
+ """The configured HMAC key is unusable."""
37
+
38
+
39
+ def load_hmac_key(env_name: str) -> bytes | None:
40
+ value = os.environ.get(env_name)
41
+ if value is None:
42
+ return None
43
+ key = value.encode("utf-8")
44
+ if len(key) < _MIN_KEY_BYTES:
45
+ raise AuditKeyError(f"{env_name} must be at least {_MIN_KEY_BYTES} bytes")
46
+ return key
47
+
48
+
49
+ def _digest(record: dict[str, Any], key: bytes | None) -> str:
50
+ body = canonical_json({name: value for name, value in record.items() if name != "hash"})
51
+ if key is None:
52
+ return hashlib.sha256(body).hexdigest()
53
+ return hmac.new(key, body, hashlib.sha256).hexdigest()
54
+
55
+
56
+ def _utc_now() -> str:
57
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
58
+
59
+
60
+ def _read_tail(path: Path) -> dict[str, Any] | None:
61
+ """Read the last record from disk. A torn final line is refused, never skipped."""
62
+ if not path.exists() or path.stat().st_size == 0:
63
+ return None
64
+ with path.open("rb") as handle:
65
+ handle.seek(0, os.SEEK_END)
66
+ position = handle.tell()
67
+ buffer = b""
68
+ while position > 0 and buffer.count(b"\n") < 2:
69
+ step = min(_TAIL_CHUNK_BYTES, position)
70
+ position -= step
71
+ handle.seek(position)
72
+ buffer = handle.read(step) + buffer
73
+ if not buffer.endswith(b"\n"):
74
+ raise AuditWriteError("the audit log ends with an incomplete record")
75
+ last_line = buffer[:-1].rsplit(b"\n", 1)[-1]
76
+ try:
77
+ record = json.loads(last_line)
78
+ except ValueError as exc:
79
+ raise AuditWriteError("the last audit record is not valid JSON") from exc
80
+ if not isinstance(record, dict):
81
+ raise AuditWriteError("the last audit record is not an object")
82
+ return record
83
+
84
+
85
+ class AuditLog:
86
+ def __init__(self, path: Path, hmac_key: bytes | None = None) -> None:
87
+ self._path = path
88
+ self._key = hmac_key
89
+ self._alg = _ALG_SHA256 if hmac_key is None else _ALG_HMAC
90
+ self._lock = FileLock(f"{path}.lock", timeout=_LOCK_TIMEOUT_SECONDS)
91
+
92
+ def append(self, fields: dict[str, Any]) -> dict[str, Any]:
93
+ """Durably append one record, chained to whatever is on disk now.
94
+
95
+ The tail is re-read every time because other processes share this file.
96
+ """
97
+ try:
98
+ with self._lock:
99
+ tail = _read_tail(self._path)
100
+ if tail is not None and tail.get("alg") != self._alg:
101
+ raise AuditWriteError(
102
+ "the audit log uses a different hash mode; start a new log to change it"
103
+ )
104
+ record: dict[str, Any] = {
105
+ **fields,
106
+ "schema_version": SCHEMA_VERSION,
107
+ "seq": 1 if tail is None else int(tail["seq"]) + 1,
108
+ "ts": _utc_now(),
109
+ "alg": self._alg,
110
+ "prev_hash": GENESIS_HASH if tail is None else str(tail["hash"]),
111
+ }
112
+ record["hash"] = _digest(record, self._key)
113
+ line = canonical_json(record) + b"\n"
114
+ with self._path.open("ab") as handle:
115
+ handle.write(line)
116
+ handle.flush()
117
+ os.fsync(handle.fileno())
118
+ return record
119
+ except AuditWriteError:
120
+ raise
121
+ except (OSError, Timeout, KeyError, TypeError, ValueError) as exc:
122
+ raise AuditWriteError(f"cannot write audit record: {type(exc).__name__}") from exc
123
+
124
+
125
+ @dataclass
126
+ class VerifyReport:
127
+ records: int = 0
128
+ errors: list[str] = field(default_factory=list)
129
+ warnings: list[str] = field(default_factory=list)
130
+ tip_seq: int = 0
131
+ tip_hash: str = GENESIS_HASH
132
+
133
+ @property
134
+ def ok(self) -> bool:
135
+ return not self.errors
136
+
137
+
138
+ def _load_anchors(path: Path, report: VerifyReport) -> list[dict[str, Any]]:
139
+ anchors: list[dict[str, Any]] = []
140
+ try:
141
+ for raw in path.read_text(encoding="utf-8").splitlines():
142
+ anchor = json.loads(raw)
143
+ if not isinstance(anchor, dict) or "seq" not in anchor or "hash" not in anchor:
144
+ raise ValueError
145
+ anchors.append(anchor)
146
+ except (OSError, ValueError):
147
+ report.errors.append("the anchor file cannot be read")
148
+ return anchors
149
+
150
+
151
+ def _record_error(record: Any, raw: bytes, key: bytes | None, prev: str, number: int) -> str | None:
152
+ """Return why a parsed line breaks the chain, or None if it is sound."""
153
+ if not isinstance(record, dict) or canonical_json(record) != raw:
154
+ return "record is not in canonical form"
155
+ expected_alg = _ALG_SHA256 if key is None else _ALG_HMAC
156
+ if record.get("alg") != expected_alg:
157
+ return f"hash mode is {record.get('alg')!r}, expected {expected_alg!r}"
158
+ if record.get("seq") != number:
159
+ return "sequence number is wrong"
160
+ if record.get("prev_hash") != prev:
161
+ return "does not chain to the previous record"
162
+ claimed = record.get("hash")
163
+ if not isinstance(claimed, str) or not hmac.compare_digest(claimed, _digest(record, key)):
164
+ return "hash does not match its contents"
165
+ return None
166
+
167
+
168
+ def verify_log(
169
+ path: Path, hmac_key: bytes | None = None, anchor_path: Path | None = None
170
+ ) -> VerifyReport:
171
+ """Check the whole chain, stopping at the first break."""
172
+ report = VerifyReport()
173
+ try:
174
+ raw_lines = path.read_bytes().split(b"\n")
175
+ except OSError as exc:
176
+ report.errors.append(f"cannot read audit log: {exc.strerror}")
177
+ return report
178
+ if raw_lines[-1] != b"":
179
+ report.errors.append(f"line {len(raw_lines)}: incomplete final record")
180
+ return report
181
+
182
+ seen_hashes: dict[int, str] = {}
183
+ open_calls: dict[str, int] = {}
184
+ previous = GENESIS_HASH
185
+ for number, raw in enumerate(raw_lines[:-1], start=1):
186
+ try:
187
+ record = json.loads(raw)
188
+ problem = _record_error(record, raw, hmac_key, previous, number)
189
+ except (ValueError, TypeError):
190
+ problem = "not valid JSON"
191
+ if problem is not None:
192
+ report.errors.append(f"line {number}: {problem}")
193
+ return report
194
+ previous = record["hash"]
195
+ seen_hashes[number] = previous
196
+ report.records = report.tip_seq = number
197
+ report.tip_hash = previous
198
+ request_id = str(record.get("request_id"))
199
+ if record.get("event") == "tool_call_decision" and record.get("decision") == "allow":
200
+ open_calls[request_id] = number
201
+ elif record.get("event") == "tool_call_outcome":
202
+ open_calls.pop(request_id, None)
203
+
204
+ for request_id, number in open_calls.items():
205
+ report.warnings.append(
206
+ f"line {number}: allowed call {request_id} has no outcome; its result is unknown"
207
+ )
208
+ if anchor_path is None:
209
+ report.warnings.append("no anchor file given, so deleted newest records go undetected")
210
+ else:
211
+ for anchor in _load_anchors(anchor_path, report):
212
+ seq = anchor["seq"]
213
+ if seq not in seen_hashes:
214
+ report.errors.append(f"anchor at seq {seq}: record is missing (log truncated)")
215
+ elif seen_hashes[seq] != anchor["hash"]:
216
+ report.errors.append(f"anchor at seq {seq}: hash does not match")
217
+ return report
218
+
219
+
220
+ def write_checkpoint(path: Path, out: Path, hmac_key: bytes | None = None) -> VerifyReport:
221
+ """Verify the log, then append its tip to an anchor file meant to be stored off-host."""
222
+ report = verify_log(path, hmac_key, anchor_path=out if out.exists() else None)
223
+ if report.ok and report.records == 0:
224
+ report.errors.append("the audit log is empty")
225
+ if not report.ok:
226
+ return report
227
+ line = canonical_json({"seq": report.tip_seq, "hash": report.tip_hash, "ts": _utc_now()})
228
+ try:
229
+ with out.open("ab") as handle:
230
+ handle.write(line + b"\n")
231
+ handle.flush()
232
+ os.fsync(handle.fileno())
233
+ except OSError as exc:
234
+ report.errors.append(f"cannot write anchor file: {exc.strerror}")
235
+ return report
repoward/canonical.py ADDED
@@ -0,0 +1,20 @@
1
+ """One invariant byte form for everything that gets hashed."""
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ def canonical_json(value: Any) -> bytes:
9
+ """Serialize to sorted, compact UTF-8 JSON.
10
+
11
+ Over this project's data (ASCII keys, no floats) the output matches RFC 8785.
12
+ """
13
+ text = json.dumps(
14
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
15
+ )
16
+ return text.encode("utf-8", "surrogatepass")
17
+
18
+
19
+ def sha256_hex(data: bytes) -> str:
20
+ return hashlib.sha256(data).hexdigest()