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.
@@ -0,0 +1,6 @@
1
+ from .snapshot import SnapshotStore
2
+ from .transaction import Transaction
3
+ from .interceptor import assess
4
+ from .audit import AuditLog
5
+ __all__ = ["SnapshotStore", "Transaction", "assess", "AuditLog"]
6
+ __version__ = "0.3.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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()