chrono-agent 0.3.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.
- chrono_agent/__init__.py +6 -0
- chrono_agent/__main__.py +4 -0
- chrono_agent/audit.py +205 -0
- chrono_agent/cli.py +494 -0
- chrono_agent/dashboard.py +87 -0
- chrono_agent/db.py +157 -0
- chrono_agent/gitlink.py +90 -0
- chrono_agent/interceptor.py +33 -0
- chrono_agent/mcp_server.py +160 -0
- chrono_agent/policy.py +164 -0
- chrono_agent/remote_audit.py +90 -0
- chrono_agent/snapshot.py +433 -0
- chrono_agent/team.py +99 -0
- chrono_agent/transaction.py +74 -0
- chrono_agent-0.3.0.dist-info/METADATA +185 -0
- chrono_agent-0.3.0.dist-info/RECORD +20 -0
- chrono_agent-0.3.0.dist-info/WHEEL +5 -0
- chrono_agent-0.3.0.dist-info/entry_points.txt +2 -0
- chrono_agent-0.3.0.dist-info/licenses/LICENSE +21 -0
- chrono_agent-0.3.0.dist-info/top_level.txt +1 -0
chrono_agent/__init__.py
ADDED
chrono_agent/__main__.py
ADDED
chrono_agent/audit.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Append-only, hash-chained audit log of every Chrono action.
|
|
2
|
+
|
|
3
|
+
Every record stores the digest of the record before it, so editing, deleting or
|
|
4
|
+
reordering an entry breaks the chain (verify() reports where). With
|
|
5
|
+
CHRONO_AUDIT_KEY set the digests are HMACs, so a writer who does not hold the
|
|
6
|
+
key cannot forge a valid chain.
|
|
7
|
+
|
|
8
|
+
This is still a local file: it proves a record was not quietly altered in place,
|
|
9
|
+
but it is not a legal-grade ledger, a remote write-once store, or proof of what a
|
|
10
|
+
program did outside the tracked files.
|
|
11
|
+
"""
|
|
12
|
+
import getpass
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
GENESIS = "GENESIS"
|
|
22
|
+
KEY_ENV = "CHRONO_AUDIT_KEY"
|
|
23
|
+
FIELDS = ("seq", "ts", "t", "actor", "host", "root", "action", "outcome", "kind",
|
|
24
|
+
"detail", "command", "snapshot", "model", "agent", "risk_score",
|
|
25
|
+
"risk_level", "files", "prev", "hash")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _actor():
|
|
29
|
+
try:
|
|
30
|
+
name = getpass.getuser()
|
|
31
|
+
except Exception:
|
|
32
|
+
name = ""
|
|
33
|
+
return (name or os.environ.get("USER") or os.environ.get("USERNAME")
|
|
34
|
+
or os.environ.get("LOGNAME") or "unknown")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _host():
|
|
38
|
+
raw = os.environ.get("CHRONO_HOST")
|
|
39
|
+
if raw:
|
|
40
|
+
return raw
|
|
41
|
+
try:
|
|
42
|
+
return socket.gethostname() or "unknown"
|
|
43
|
+
except Exception:
|
|
44
|
+
return "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _key():
|
|
48
|
+
raw = os.environ.get(KEY_ENV)
|
|
49
|
+
return raw.encode("utf-8") if raw else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _digest(payload, key):
|
|
53
|
+
body = json.dumps(payload, sort_keys=True, separators=(",", ":"),
|
|
54
|
+
ensure_ascii=True).encode("utf-8")
|
|
55
|
+
return (hmac.new(key, body, hashlib.sha256) if key
|
|
56
|
+
else hashlib.sha256(body)).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AuditLog:
|
|
60
|
+
"""One append-only log per project, stored at .chrono/audit.jsonl."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, root="."):
|
|
63
|
+
self.dir = Path(root).resolve() / ".chrono"
|
|
64
|
+
self.path = self.dir / "audit.jsonl"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def enabled(self):
|
|
68
|
+
return self.dir.is_dir() and not self.path.is_symlink()
|
|
69
|
+
|
|
70
|
+
def _raw_lines(self):
|
|
71
|
+
if not self.path.is_file():
|
|
72
|
+
return []
|
|
73
|
+
with open(self.path, "rb") as f:
|
|
74
|
+
return f.read().decode("utf-8", errors="replace").splitlines()
|
|
75
|
+
|
|
76
|
+
def read(self):
|
|
77
|
+
"""Parsed records; unreadable lines come back marked, never dropped."""
|
|
78
|
+
out = []
|
|
79
|
+
for n, line in enumerate(self._raw_lines(), 1):
|
|
80
|
+
if not line.strip():
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
rec = json.loads(line)
|
|
84
|
+
if not isinstance(rec, dict):
|
|
85
|
+
raise ValueError("not an object")
|
|
86
|
+
except (ValueError, TypeError):
|
|
87
|
+
rec = {"seq": None, "action": "CORRUPT", "outcome": "unreadable",
|
|
88
|
+
"detail": "line " + str(n)}
|
|
89
|
+
out.append(rec)
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
def record(self, action, outcome="ok", **fields):
|
|
93
|
+
"""Append one event. Returns None when the project is not initialised."""
|
|
94
|
+
if not self.enabled:
|
|
95
|
+
return None
|
|
96
|
+
if self.path.is_symlink():
|
|
97
|
+
raise RuntimeError("Unsafe symlink in audit storage: " + str(self.path))
|
|
98
|
+
key = _key()
|
|
99
|
+
fields.setdefault("model", os.environ.get("CHRONO_MODEL"))
|
|
100
|
+
fields.setdefault("agent", os.environ.get("CHRONO_AGENT"))
|
|
101
|
+
lines = self._raw_lines()
|
|
102
|
+
prev = GENESIS
|
|
103
|
+
for line in reversed(lines):
|
|
104
|
+
if not line.strip():
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
last = json.loads(line)
|
|
108
|
+
except ValueError:
|
|
109
|
+
last = None
|
|
110
|
+
if isinstance(last, dict) and isinstance(last.get("hash"), str):
|
|
111
|
+
prev = last["hash"]
|
|
112
|
+
break
|
|
113
|
+
rec = {"seq": len([x for x in lines if x.strip()]) + 1,
|
|
114
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
115
|
+
"t": time.time(),
|
|
116
|
+
"actor": _actor(),
|
|
117
|
+
"host": _host(),
|
|
118
|
+
"root": str(self.dir.parent),
|
|
119
|
+
"action": action,
|
|
120
|
+
"outcome": outcome}
|
|
121
|
+
for name in ("detail", "command", "snapshot", "model", "agent", "kind",
|
|
122
|
+
"risk_score", "risk_level", "files"):
|
|
123
|
+
value = fields.get(name)
|
|
124
|
+
if value is not None:
|
|
125
|
+
rec[name] = value
|
|
126
|
+
rec["prev"] = prev
|
|
127
|
+
rec["hash"] = _digest({k: v for k, v in rec.items() if k != "hash"}, key)
|
|
128
|
+
body = (json.dumps(rec, sort_keys=True, ensure_ascii=True) + "\n").encode("utf-8")
|
|
129
|
+
with open(self.path, "ab") as f:
|
|
130
|
+
f.write(body)
|
|
131
|
+
f.flush()
|
|
132
|
+
os.fsync(f.fileno())
|
|
133
|
+
return rec
|
|
134
|
+
|
|
135
|
+
def head(self):
|
|
136
|
+
"""(seq, hash) of the newest record; (0, GENESIS) when the log is empty."""
|
|
137
|
+
for line in reversed(self._raw_lines()):
|
|
138
|
+
if not line.strip():
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
rec = json.loads(line)
|
|
142
|
+
except ValueError:
|
|
143
|
+
return (None, None)
|
|
144
|
+
if isinstance(rec, dict) and isinstance(rec.get("hash"), str):
|
|
145
|
+
return (rec.get("seq"), rec["hash"])
|
|
146
|
+
return (None, None)
|
|
147
|
+
return (0, GENESIS)
|
|
148
|
+
|
|
149
|
+
def verify(self):
|
|
150
|
+
"""Walk the chain and report the first record that does not add up."""
|
|
151
|
+
key = _key()
|
|
152
|
+
lines = [x for x in self._raw_lines() if x.strip()]
|
|
153
|
+
if not lines:
|
|
154
|
+
return {"ok": True, "records": 0, "broken_at": None, "reason": "no audit records"}
|
|
155
|
+
if key is None and os.environ.get("CHRONO_AUDIT_REQUIRE_KEY"):
|
|
156
|
+
return {"ok": False, "records": len(lines), "broken_at": None,
|
|
157
|
+
"reason": "signed log but " + KEY_ENV + " is not set"}
|
|
158
|
+
prev = GENESIS
|
|
159
|
+
for n, line in enumerate(lines, 1):
|
|
160
|
+
try:
|
|
161
|
+
rec = json.loads(line)
|
|
162
|
+
except ValueError:
|
|
163
|
+
return {"ok": False, "records": len(lines), "broken_at": n,
|
|
164
|
+
"reason": "record is not valid JSON"}
|
|
165
|
+
if not isinstance(rec, dict):
|
|
166
|
+
return {"ok": False, "records": len(lines), "broken_at": n,
|
|
167
|
+
"reason": "record is not an object"}
|
|
168
|
+
stored = rec.get("hash")
|
|
169
|
+
if not isinstance(stored, str):
|
|
170
|
+
return {"ok": False, "records": len(lines), "broken_at": n,
|
|
171
|
+
"reason": "record has no digest"}
|
|
172
|
+
if rec.get("prev") != prev:
|
|
173
|
+
return {"ok": False, "records": len(lines), "broken_at": n,
|
|
174
|
+
"reason": "previous link does not match (record deleted or reordered)"}
|
|
175
|
+
if _digest({k: v for k, v in rec.items() if k != "hash"}, key) != stored:
|
|
176
|
+
why = "content does not match its digest (record edited)"
|
|
177
|
+
if key is None:
|
|
178
|
+
why += "; if this log is HMAC-signed, set " + KEY_ENV
|
|
179
|
+
return {"ok": False, "records": len(lines), "broken_at": n,
|
|
180
|
+
"reason": why}
|
|
181
|
+
prev = stored
|
|
182
|
+
return {"ok": True, "records": len(lines), "broken_at": None,
|
|
183
|
+
"reason": "chain intact" + (" (HMAC-signed)" if key else "")}
|
|
184
|
+
|
|
185
|
+
def rows(self, limit=None, action=None):
|
|
186
|
+
recs = self.read()
|
|
187
|
+
if action:
|
|
188
|
+
recs = [r for r in recs if r.get("action") == action]
|
|
189
|
+
return recs[-limit:] if limit else recs
|
|
190
|
+
|
|
191
|
+
def to_csv(self, records):
|
|
192
|
+
head = ["seq", "ts", "actor", "host", "action", "outcome", "snapshot",
|
|
193
|
+
"model", "agent", "risk_score", "risk_level", "files", "detail",
|
|
194
|
+
"command", "hash"]
|
|
195
|
+
import csv, io
|
|
196
|
+
buf = io.StringIO()
|
|
197
|
+
writer = csv.DictWriter(buf, fieldnames=head, extrasaction="ignore",
|
|
198
|
+
lineterminator="\n")
|
|
199
|
+
writer.writeheader()
|
|
200
|
+
for rec in records:
|
|
201
|
+
row = {k: rec.get(k, "") for k in head}
|
|
202
|
+
if isinstance(row.get("files"), dict):
|
|
203
|
+
row["files"] = " ".join("%s=%s" % kv for kv in sorted(row["files"].items()))
|
|
204
|
+
writer.writerow(row)
|
|
205
|
+
return buf.getvalue()
|