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 +1 -0
- repoward/adapters/__init__.py +1 -0
- repoward/adapters/ci.py +50 -0
- repoward/adapters/github.py +150 -0
- repoward/approvals/__init__.py +1 -0
- repoward/approvals/store.py +199 -0
- repoward/audit.py +235 -0
- repoward/canonical.py +20 -0
- repoward/catalog.py +29 -0
- repoward/cli.py +391 -0
- repoward/flow.py +239 -0
- repoward/pipeline.py +333 -0
- repoward/policy/__init__.py +1 -0
- repoward/policy/engine.py +101 -0
- repoward/policy/loader.py +245 -0
- repoward/policy/models.py +100 -0
- repoward/principal.py +15 -0
- repoward/py.typed +0 -0
- repoward/ratelimit.py +39 -0
- repoward/registry.py +68 -0
- repoward/sanitize.py +207 -0
- repoward/server.py +101 -0
- repoward/setup.py +118 -0
- repoward/storage.py +26 -0
- repoward/tools/__init__.py +1 -0
- repoward/tools/ci_read.py +121 -0
- repoward/tools/github_dangerous.py +135 -0
- repoward/tools/github_read.py +312 -0
- repoward/tools/github_write.py +239 -0
- repoward/tools/system.py +26 -0
- repoward/validation.py +63 -0
- repoward-0.1.0.dist-info/METADATA +317 -0
- repoward-0.1.0.dist-info/RECORD +36 -0
- repoward-0.1.0.dist-info/WHEEL +4 -0
- repoward-0.1.0.dist-info/entry_points.txt +2 -0
- repoward-0.1.0.dist-info/licenses/LICENSE +21 -0
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."""
|
repoward/adapters/ci.py
ADDED
|
@@ -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()
|