memdebug 0.2.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,218 @@
1
+ """The viewer's stylesheet. System fonts only: nothing is fetched from anywhere.
2
+
3
+ Design rules, so later changes keep the same character:
4
+
5
+ * Colour means change. The page itself is ink on paper; the only saturated colours are green (added),
6
+ blue (changed), red (deleted) and signal amber (changed outside the store's own history). Shape says it
7
+ too, so nothing depends on colour alone.
8
+ * The timeline is a chain. Every entry hangs on one spine; what happened decides the shape of its node.
9
+ * Two type roles. Interface text is a humanist sans close to Open Sans (Segoe UI on Windows; Open Sans or Noto
10
+ Sans if installed, otherwise the system font). The agent's own words, which are usually markdown, are set in
11
+ the monospace face of a code editor, the same face used for ids and hashes, so they look like the file they
12
+ came from. Only fonts already on the computer are used.
13
+ * No cards, pills or shadows. Structure comes from rules, spacing and the chain.
14
+ """
15
+
16
+ _DARK = """ --paper:#0d1a23;--sheet:#12232e;--ink:#dce6eb;--muted:#9bb0bc;--faint:#7c919d;--rule:#2a4251;--wash:#193040;
17
+ --add:#58c995;--add-wash:#133a2a;--chg:#86abff;--chg-wash:#1a2d54;--del:#ff8f7a;--del-wash:#4a211b;
18
+ --out:#f2b632;--out-ink:#f6d98b;--out-wash:#3d2f0f;--focus:#86abff;--on:#0d1a23;--on-out:#2b1c00;
19
+ """
20
+
21
+ CSS = """
22
+ :root{
23
+ color-scheme:light dark;
24
+ --paper:#edf0f2;--sheet:#f8f9fa;--ink:#10283a;--muted:#52687a;--faint:#5a6e7e;--rule:#c2cdd4;--wash:#e0e7ec;
25
+ --add:#17704a;--add-wash:#d8eee2;--chg:#2453b0;--chg-wash:#dce6f7;--del:#b0301d;--del-wash:#f7dfda;
26
+ --out:#e9a800;--out-ink:#5a3b00;--out-wash:#fbedc2;--focus:#2453b0;--on:#fff;--on-out:#3a2600;
27
+ --page:1480px;--seq:52px;--rail:46px;--node:28px;--gutter:clamp(16px,3.2vw,56px);
28
+ --ui:"Open Sans","Segoe UI Variable Text","Segoe UI","Noto Sans",Roboto,system-ui,"Helvetica Neue",Arial,sans-serif;
29
+ --mono:"Cascadia Mono","Cascadia Code",Consolas,"SF Mono",Menlo,ui-monospace,"DejaVu Sans Mono",monospace;
30
+ }
31
+ @media (prefers-color-scheme:dark){:root:not(.theme-light){@@DARK@@}}
32
+ :root.theme-dark{color-scheme:dark;@@DARK@@}
33
+ :root.theme-light{color-scheme:light}
34
+ *{box-sizing:border-box}
35
+ html{-webkit-text-size-adjust:100%}
36
+ body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 var(--ui)}
37
+ a{color:var(--ink);text-decoration-thickness:1px;text-underline-offset:3px}
38
+ a:hover{text-decoration-thickness:2px}
39
+ :focus-visible{outline:3px solid var(--focus);outline-offset:2px}
40
+ code,pre,.mono{font-family:var(--mono);font-size:13px}
41
+ h1,h2,h3{font-family:var(--ui);margin:0;line-height:1.2}
42
+ h1{font-size:30px;font-weight:600;letter-spacing:-.012em;max-width:26ch}
43
+ h2{font-size:18px;font-weight:600;margin:40px 0 12px}
44
+ h3{font-size:15px;font-weight:600;margin:22px 0 6px}
45
+ p{margin:0 0 10px}
46
+ small{font-size:13px;color:var(--muted)}
47
+ .sub{color:var(--muted);margin:10px 0 28px;max-width:62ch}
48
+
49
+ /* masthead */
50
+ header.top{max-width:var(--page);margin:0 auto;padding:22px var(--gutter) 0;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 26px}
51
+ .brand{position:relative;display:inline-block;padding-left:34px;font-size:21px;font-weight:600;letter-spacing:-.01em}
52
+ .brand::before,.brand::after{content:"";position:absolute;top:50%;width:17px;height:10px;margin-top:-4px;border:2px solid var(--ink);border-radius:6px}
53
+ .brand::before{left:0}.brand::after{left:9px}
54
+ .where{font:12.5px var(--mono);color:var(--muted)}
55
+ .ro{font-size:13px;color:var(--muted)}
56
+ nav.tabs{margin-left:auto;display:flex;flex-wrap:wrap;gap:0 24px}
57
+ nav.tabs a{padding:10px 0 8px;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent}
58
+ nav.tabs a:hover{color:var(--ink);border-bottom-color:var(--rule)}
59
+ nav.tabs a[aria-current=page]{color:var(--ink);font-weight:600;border-bottom-color:var(--ink)}
60
+ main{max-width:var(--page);margin:0 auto;padding:30px var(--gutter) 60px}
61
+ footer{max-width:var(--page);margin:0 auto;padding:0 var(--gutter) 40px;color:var(--muted);font-size:13px}
62
+ footer::before{content:"";display:block;border-top:1px solid var(--rule);margin-bottom:18px}
63
+
64
+ /* colour theme switch: three links, the current one filled */
65
+ .theme{display:flex;align-self:center;margin-left:6px;border:1px solid var(--rule);border-radius:4px;overflow:hidden}
66
+ .theme a{padding:5px 12px;font-size:13px;line-height:1.4;color:var(--muted);text-decoration:none}
67
+ .theme a:hover{background:var(--wash);color:var(--ink)}
68
+ .theme a[aria-current=true]{background:var(--ink);color:var(--paper);font-weight:600}
69
+ @media (max-width:860px){.theme{margin-left:0}}
70
+
71
+ /* filters */
72
+ .filters{display:flex;flex-wrap:wrap;gap:0 22px;margin:0 0 26px}
73
+ .filters a{display:inline-flex;align-items:center;min-height:40px;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent}
74
+ .filters a:hover{color:var(--ink);border-bottom-color:var(--rule)}
75
+ .filters a[aria-current=page]{color:var(--ink);font-weight:600;border-bottom-color:var(--ink)}
76
+
77
+ /* timeline layout: a list on the left, a wide reading pane on the right */
78
+ .layout{display:grid;grid-template-columns:minmax(380px,500px) minmax(0,1fr);gap:clamp(28px,3vw,48px);align-items:start}
79
+ .sheet{position:sticky;top:16px;max-height:calc(100vh - 32px);overflow:auto;background:var(--sheet);border-top:3px solid var(--ink);padding:20px 26px 26px}
80
+ .sheet-main{min-width:0}
81
+ .sheet-side{margin-top:26px;padding-top:4px;border-top:1px solid var(--rule)}
82
+ .sheet-side h3{margin-top:14px}
83
+ @media (max-width:1100px){.layout{grid-template-columns:minmax(0,1fr);gap:28px}.layout.has-sheet .sheet{order:-1}.sheet{position:static;max-height:78vh}}
84
+ @media (max-width:860px){h1{font-size:25px}nav.tabs{margin-left:0;width:100%}}
85
+ @media (max-width:640px){:root{--seq:38px;--rail:32px;--node:26px}}
86
+
87
+ /* the chain: one round dot per entry. Its colour and icon say what happened. */
88
+ .chain{list-style:none;margin:0;padding:0;max-width:980px}
89
+ .chain>li{position:relative;display:grid;grid-template-columns:var(--seq) var(--rail) minmax(0,1fr);
90
+ background:linear-gradient(var(--rule),var(--rule)) calc(var(--seq) + var(--rail)/2 - 1px) 0/2px 100% no-repeat}
91
+ .chain>li:first-child{background-position:calc(var(--seq) + var(--rail)/2 - 1px) 25px;background-size:2px calc(100% - 25px)}
92
+ .chain>li:last-child{background-size:2px 25px}
93
+ .chain.goes-on>li:last-child{background-size:2px 100%}
94
+ .chain>li:first-child:last-child{background:none}
95
+ .chain>li::before{content:"";position:absolute;left:calc(var(--seq) + var(--rail)/2 - var(--node)/2);top:11px;width:var(--node);height:var(--node);
96
+ border-radius:50%;background-color:var(--disc,var(--ink));background-repeat:no-repeat;transition:none}
97
+ .seq{grid-column:1;justify-self:end;padding-top:14px;font:12px var(--mono);color:var(--faint)}
98
+ .chain>li>a.row{grid-column:3}
99
+ .n-add{--disc:var(--add)}.n-update{--disc:var(--chg)}.n-delete{--disc:var(--del)}
100
+ .n-external{--disc:var(--out);--on:var(--on-out)}
101
+ .n-snapshot,.n-rollback{--disc:var(--ink);--on:var(--paper)}.n-snap-del{--disc:var(--faint);--on:var(--paper)}
102
+ /* icons, drawn with gradients so they need no image files */
103
+ .chain>li.n-add::before{background-image:linear-gradient(var(--on),var(--on)),linear-gradient(var(--on),var(--on));
104
+ background-position:8px 13px,13px 8px;background-size:12px 2px,2px 12px}
105
+ .chain>li.n-delete::before,.chain>li.n-snap-del::before{
106
+ background-image:linear-gradient(var(--disc),var(--disc)),linear-gradient(var(--disc),var(--disc)),linear-gradient(var(--on),var(--on)),linear-gradient(var(--on),var(--on)),linear-gradient(var(--on),var(--on));
107
+ background-position:11px 14px,15px 14px,9px 12px,7px 9px,11px 7px;background-size:2px 5px,2px 5px,10px 9px,14px 2px,6px 2px}
108
+ .chain>li.n-snapshot::before{
109
+ background-image:radial-gradient(circle at 14px 15.5px,var(--disc) 0 3.4px,transparent 4px),linear-gradient(var(--on),var(--on)),linear-gradient(var(--on),var(--on));
110
+ background-position:0 0,6px 10px,11px 7px;background-size:100% 100%,16px 11px,6px 3px}
111
+ .chain>li.n-rollback::before{background-image:linear-gradient(var(--on),var(--on));background-position:9px 13px;background-size:11px 2px}
112
+ .chain>li.n-rollback::after{content:"";position:absolute;width:8px;height:8px;left:calc(var(--seq) + var(--rail)/2 - 6px);
113
+ top:calc(11px + var(--node)/2 - 4px);transform:rotate(45deg);border-left:2px solid var(--on);border-bottom:2px solid var(--on)}
114
+ .chain>li.n-external::before{background-image:linear-gradient(var(--on),var(--on)),linear-gradient(var(--on),var(--on));
115
+ background-position:13px 7px,13px 19px;background-size:2px 10px,2px 2px}
116
+ .chain>li.n-update::after{content:"";position:absolute;width:5px;height:15px;left:calc(var(--seq) + var(--rail)/2 - 2.5px);top:calc(11px + var(--node)/2 - 7.5px);
117
+ transform:rotate(45deg);background:linear-gradient(var(--on) 0 18%,transparent 18% 27%,var(--on) 27%);clip-path:polygon(0 0,100% 0,100% 78%,50% 100%,0 78%)}
118
+ .chain>li:has(a[aria-current=true])::before{box-shadow:0 0 0 3px var(--paper),0 0 0 5px var(--ink)}
119
+
120
+ a.row{display:block;position:relative;padding:10px 14px 12px;margin:0 0 6px;color:var(--ink);text-decoration:none}
121
+ a.row:hover{background:var(--wash)}
122
+ a.row[aria-current=true]{background:var(--wash);box-shadow:inset 3px 0 0 var(--ink)}
123
+ a.row:hover .what{text-decoration:underline}
124
+ .top{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px}
125
+ .what{font-size:15.5px;font-weight:600;overflow-wrap:anywhere}
126
+ .when{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap}
127
+ .mem{margin:5px 0 0;font:13.5px/1.6 var(--mono);overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}
128
+ .n-delete .mem{color:var(--muted);text-decoration:line-through;text-decoration-thickness:1px}
129
+ .meta{margin-top:4px;font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:0 14px}
130
+ .flag{color:var(--out-ink);background:var(--out-wash);padding:0 6px;box-shadow:inset 0 -2px 0 var(--out)}
131
+ .n-external>a.row{background:var(--out-wash);box-shadow:inset 4px 0 0 var(--out)}
132
+ .n-external>a.row[aria-current=true]{box-shadow:inset 4px 0 0 var(--out-ink)}
133
+ .label{font-size:15px}
134
+
135
+ /* status badges */
136
+ .badge{display:inline-block;padding:1px 8px;border-radius:4px;font-size:12px;font-weight:600;letter-spacing:.04em;line-height:1.5;white-space:nowrap}
137
+ .op-add{background:var(--add-wash);color:var(--add)}
138
+ .op-update{background:var(--chg-wash);color:var(--chg)}
139
+ .op-delete{background:var(--del-wash);color:var(--del)}
140
+ .op-external{background:var(--out-wash);color:var(--out-ink);box-shadow:inset 0 0 0 1px var(--out)}
141
+ .op-rollback{background:var(--ink);color:var(--paper)}
142
+ .op-snapshot,.op-snap-del{background:var(--wash);color:var(--ink);box-shadow:inset 0 0 0 1px var(--rule)}
143
+
144
+ /* the open entry */
145
+ ul.hints{list-style:none;margin:6px 0 12px;padding:0}
146
+ ul.hints li{padding:6px 0;border-bottom:1px solid var(--rule);overflow-wrap:anywhere}
147
+ ul.hints code{font-family:var(--mono);font-size:13px;color:var(--muted)}
148
+ .flag.hint{background:var(--wash);box-shadow:inset 0 -2px 0 var(--rule);color:var(--ink)}
149
+ ul.files{list-style:none;margin:6px 0 14px;padding:0;font:14px/1.8 var(--mono)}
150
+ .what-did{display:inline-block;min-width:8ch;color:var(--muted)}
151
+ ul.files small{color:var(--muted);font-family:var(--ui)}
152
+ .sheet h2{margin:0 0 6px;font-size:24px;overflow-wrap:anywhere}
153
+ .sheet .where2{margin:0 0 14px;font:12.5px var(--mono);color:var(--muted)}
154
+ .verdict{font-weight:600;margin:0 0 6px}
155
+ .why{color:var(--muted);margin:0 0 4px}
156
+ .sheet h3{margin-top:22px}
157
+ .redline,.quote{font:14px/1.75 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 4px}
158
+ .ins{background:var(--chg-wash);color:var(--chg);text-decoration:underline;text-decoration-thickness:2px;text-underline-offset:3px;padding:0 1px}
159
+ .rm + .ins{margin-left:.5ch}
160
+ .rm{background:var(--del-wash);color:var(--del);text-decoration:line-through;text-decoration-thickness:2px;padding:0 1px}
161
+ .redline .ln{min-height:1.75em}
162
+ .ln.gone,.ln.added{margin:3px 0;padding:2px 10px 2px calc(10px + 2ch);text-indent:-2ch;border-left:3px solid}
163
+ .ln.gone{border-left-color:var(--del);background:var(--del-wash)}
164
+ .ln.added{border-left-color:var(--chg);background:var(--chg-wash)}
165
+ .ln.gone::before{content:"\\2212\\00a0";color:var(--del)}
166
+ .ln.added::before{content:"+\\00a0";color:var(--chg)}
167
+ .ln.gone .rm,.ln.added .ins{background:none;text-decoration:none;padding:0}
168
+ .ln.gone .rm{color:var(--del)}.ln.added .ins{color:var(--chg)}
169
+ .fold{margin:8px 0;padding:3px 0;font:13px var(--ui);color:var(--muted);text-align:center;border-top:1px dashed var(--rule);border-bottom:1px dashed var(--rule)}
170
+ .quote.gone{color:var(--muted);text-decoration:line-through;text-decoration-thickness:1px}
171
+ .change-key{margin:0 0 8px;font-size:13px;color:var(--muted)}
172
+ .change-key .ins,.change-key .rm{padding:0 4px}
173
+ dl.facts{display:grid;grid-template-columns:96px minmax(0,1fr);gap:7px 14px;margin:0;font-size:14px}
174
+ dl.facts dt{color:var(--muted)}dl.facts dd{margin:0;overflow-wrap:anywhere}
175
+ details{margin:12px 0 0}
176
+ summary{cursor:pointer;color:var(--muted)}summary:hover{color:var(--ink)}
177
+ pre{margin:6px 0;padding:10px 12px;background:var(--wash);white-space:pre-wrap;overflow-wrap:anywhere;max-height:420px;overflow:auto;font:13px/1.5 var(--mono)}
178
+ pre .add{color:var(--add);display:block}pre .del{color:var(--del);display:block}pre .hunk{color:var(--muted);display:block}
179
+
180
+ /* overview */
181
+ .headline{max-width:24ch}
182
+ .fingerprint{display:block;margin:4px 0 8px;padding:10px 12px;background:var(--sheet);border-left:3px solid var(--ink);overflow-wrap:anywhere}
183
+ .attention{margin-top:34px;padding-top:2px}
184
+ .attention h2{margin-top:0}
185
+
186
+ /* tables */
187
+ .table-wrap{overflow-x:auto}
188
+ table{border-collapse:collapse;width:100%}
189
+ th,td{text-align:left;padding:10px 14px 10px 0;border-bottom:1px solid var(--rule);vertical-align:top;overflow-wrap:anywhere}
190
+ th{font-weight:600;font-size:13px;color:var(--muted);border-bottom:2px solid var(--ink)}
191
+ td:first-child,th:first-child{padding-left:0}
192
+ tbody tr:hover{background:var(--wash)}
193
+ td .mem{margin:0;font-size:13.5px}
194
+
195
+ /* compare */
196
+ form.pick{display:flex;flex-wrap:wrap;gap:14px 22px;align-items:end;margin:0 0 30px}
197
+ form.pick label{display:flex;flex-direction:column;gap:5px;color:var(--muted);font-size:13px}
198
+ select{min-height:42px;padding:0 10px;border:0;border-bottom:2px solid var(--ink);border-radius:0;background:var(--sheet);color:var(--ink);font:15px var(--ui)}
199
+ button{min-height:42px;padding:0 20px;border:0;border-radius:2px;background:var(--ink);color:var(--paper);font:600 15px var(--ui);cursor:pointer}
200
+ button:hover{background:var(--chg)}
201
+ input[type=checkbox]{width:18px;height:18px;margin-right:6px;accent-color:var(--ink);vertical-align:-3px}
202
+ .changes{display:flex;flex-direction:column;max-width:1100px}
203
+ .change{display:grid;grid-template-columns:minmax(150px,200px) minmax(0,1fr);gap:4px 28px;padding:16px 0;border-top:1px solid var(--rule)}
204
+ .change:first-child{border-top:2px solid var(--ink)}
205
+ .change .who{display:flex;flex-direction:column;align-items:flex-start;gap:5px}
206
+ @media (max-width:640px){.change{grid-template-columns:minmax(0,1fr)}}
207
+
208
+ /* notices, pager */
209
+ .notice{max-width:980px;margin:16px 0;padding:11px 16px;border-left:5px solid var(--out);background:var(--out-wash);color:var(--out-ink)}
210
+ .notice.ok{border-left-color:var(--add);background:var(--add-wash);color:var(--add)}
211
+ .notice.bad{border-left-color:var(--del);background:var(--del-wash);color:var(--del)}
212
+ .pager{display:flex;gap:26px;margin:20px 0 0 calc(var(--seq) + var(--rail) + 14px)}
213
+ .pager a{min-height:40px;display:inline-flex;align-items:center}
214
+ .state{font-size:19px;font-weight:600;margin:0 0 14px;max-width:44ch}
215
+ .state.ok{color:var(--add)}.state.bad{color:var(--del)}
216
+ ul.problems{padding-left:20px}
217
+ """
218
+ CSS = CSS.replace("@@DARK@@", _DARK.strip())
memdebug/witness.py ADDED
@@ -0,0 +1,199 @@
1
+ """A witness: a second copy of the ledger's head hash, kept somewhere else.
2
+
3
+ The ledger's hash chain proves nothing was edited *inside* it, but whoever can rewrite the whole ledger file can build
4
+ a new valid chain, or cut off its newest entries, and `verify` will still say "intact". A witness closes that gap: each
5
+ time you run `memdebug witness --file PATH` one line is appended to a text file recording the ledger's newest entry and
6
+ its hash. Later, `memdebug verify --witness PATH` checks that the ledger still contains exactly that entry. A ledger that
7
+ was rewritten or truncated can no longer produce it.
8
+
9
+ The witness file chains its own lines (each line carries the hash of the line before), so editing or deleting a line
10
+ shows up as well. It holds hashes and counts only, never memory text.
11
+
12
+ It is only as strong as its separation from the ledger: put the file where an attacker who controls the ledger cannot
13
+ also rewrite it (another drive or machine, a synced folder, a USB stick, a repository you push to). A witness on the same
14
+ disk buys little, and memdebug says so.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import re
22
+ import stat
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ from .adapters.markdown_git import _is_reparse_point
28
+ from .errors import MemdebugError
29
+ from .ledger import Ledger
30
+ from .textsafe import safe_text
31
+
32
+ MAX_WITNESS_BYTES = 16 * 1024 * 1024
33
+ MAX_LINE_BYTES = 1024
34
+ GENESIS = "0" * 64
35
+ _HASH = re.compile(r"^[0-9a-f]{64}\Z")
36
+ _KEYS = {"v", "seq", "head", "count", "at", "prev"}
37
+
38
+
39
+ class WitnessError(MemdebugError):
40
+ """The witness file cannot be used (not a plain file, corrupt, unreadable)."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class WitnessLine:
45
+ seq: int
46
+ head: str
47
+ count: int
48
+ at: str
49
+ prev: str
50
+ text: str # the exact line, without its line break
51
+
52
+ @property
53
+ def digest(self) -> str:
54
+ return hashlib.sha256(self.text.encode("utf-8")).hexdigest()
55
+
56
+
57
+ @dataclass
58
+ class WitnessCheck:
59
+ ok: bool
60
+ problems: list[str] = field(default_factory=list)
61
+ warnings: list[str] = field(default_factory=list)
62
+ lines: int = 0
63
+ last_seq: int = 0
64
+ unwitnessed: int = 0 # ledger entries newer than the last witnessed one
65
+
66
+
67
+ def _line_text(seq: int, head: str, count: int, at: str, prev: str) -> str:
68
+ return json.dumps({"v": 1, "seq": seq, "head": head, "count": count, "at": at, "prev": prev},
69
+ sort_keys=True, separators=(",", ":"), ensure_ascii=True)
70
+
71
+
72
+ def _check_path(path: Path, *, must_exist: bool) -> None:
73
+ try:
74
+ info = os.lstat(path)
75
+ except FileNotFoundError:
76
+ if must_exist:
77
+ raise WitnessError("the witness file does not exist") from None
78
+ if not path.parent.is_dir():
79
+ raise WitnessError("the folder for the witness file does not exist") from None
80
+ return
81
+ except OSError as exc:
82
+ raise WitnessError(f"cannot look at the witness file ({exc.strerror})") from exc
83
+ if stat.S_ISLNK(info.st_mode) or _is_reparse_point(info) or not stat.S_ISREG(info.st_mode):
84
+ raise WitnessError("the witness file must be a plain file, not a link or a folder")
85
+
86
+
87
+ def read_witness(path: Path) -> tuple[list[WitnessLine], list[str]]:
88
+ """The lines of a witness file and any problems with the file itself (chain, format, order)."""
89
+ _check_path(path, must_exist=True)
90
+ try:
91
+ raw = path.read_bytes() if path.stat().st_size <= MAX_WITNESS_BYTES else None
92
+ except OSError as exc:
93
+ raise WitnessError(f"cannot read the witness file ({exc.strerror})") from exc
94
+ if raw is None:
95
+ raise WitnessError("the witness file is unexpectedly large")
96
+ problems: list[str] = []
97
+ lines: list[WitnessLine] = []
98
+ try:
99
+ text = raw.decode("utf-8")
100
+ except UnicodeDecodeError:
101
+ return [], ["the witness file is not valid text"]
102
+ # Line-ending style and a leading byte-order mark carry no meaning (the chain hashes each line's text), and they change
103
+ # when the file passes through git's autocrlf or an editor, so they must not look like tampering.
104
+ text = text.removeprefix("\ufeff").replace("\r\n", "\n")
105
+ previous = GENESIS
106
+ parts = text.split("\n")
107
+ if parts and parts[-1] == "":
108
+ parts.pop() # the final line break
109
+ for number, line in enumerate(parts, 1):
110
+ if len(line.encode("utf-8")) > MAX_LINE_BYTES:
111
+ problems.append(f"witness line {number} is too long")
112
+ break
113
+ try:
114
+ data = json.loads(line)
115
+ except ValueError:
116
+ problems.append(f"witness line {number} is not valid")
117
+ break
118
+ if (not isinstance(data, dict) or set(data) != _KEYS or data["v"] != 1 or not isinstance(data["seq"], int)
119
+ or isinstance(data["seq"], bool) or data["seq"] < 1 or not isinstance(data["count"], int)
120
+ or isinstance(data["count"], bool) or data["count"] < data["seq"] or not isinstance(data["head"], str)
121
+ or not _HASH.match(data["head"]) or not isinstance(data["prev"], str) or not isinstance(data["at"], str)
122
+ or not _HASH.match(data["prev"])):
123
+ problems.append(f"witness line {number} has unexpected contents")
124
+ break
125
+ canonical = _line_text(data["seq"], data["head"], data["count"], data["at"], data["prev"])
126
+ if canonical != line:
127
+ problems.append(f"witness line {number} was not written by memdebug (its form differs)")
128
+ break
129
+ entry = WitnessLine(data["seq"], data["head"], data["count"], data["at"], data["prev"], line)
130
+ if entry.prev != previous:
131
+ problems.append(f"witness line {number} does not follow the line before it: a line was changed or removed")
132
+ if lines and entry.seq < lines[-1].seq:
133
+ problems.append(f"witness line {number} goes back in time")
134
+ lines.append(entry)
135
+ previous = entry.digest
136
+ return lines, problems
137
+
138
+
139
+ def append_witness(ledger: Ledger, path: Path, *, now: datetime | None = None) -> tuple[WitnessLine, bool]:
140
+ """Record the ledger's newest entry in the witness file. Returns the line and whether it was newly written."""
141
+ _check_path(path, must_exist=False)
142
+ existing: list[WitnessLine] = []
143
+ if os.path.lexists(path):
144
+ existing, problems = read_witness(path)
145
+ if problems:
146
+ raise WitnessError("the witness file is damaged (" + problems[0] + "), so it will not be extended")
147
+ entries = ledger.entries()
148
+ if not entries:
149
+ raise WitnessError("the ledger is empty; there is nothing to witness yet")
150
+ newest = entries[-1]
151
+ if existing and existing[-1].seq == newest.seq and existing[-1].head == newest.hash:
152
+ return existing[-1], False
153
+ at = (now or datetime.now(timezone.utc)).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
154
+ prev = existing[-1].digest if existing else GENESIS
155
+ text = _line_text(newest.seq, newest.hash, len(entries), at, prev)
156
+ fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0), 0o644)
157
+ try:
158
+ os.write(fd, (text + "\n").encode("utf-8"))
159
+ os.fsync(fd)
160
+ finally:
161
+ os.close(fd)
162
+ return WitnessLine(newest.seq, newest.hash, len(entries), at, prev, text), True
163
+
164
+
165
+ def same_disk(ledger_path: Path, witness_path: Path) -> bool:
166
+ try:
167
+ return os.stat(ledger_path).st_dev == os.stat(witness_path.parent).st_dev
168
+ except OSError:
169
+ return False
170
+
171
+
172
+ SAME_DISK_WARNING = ("the witness is on the same disk as the ledger, so it gives little protection if that disk is "
173
+ "compromised; keep it on another drive, machine or synced folder")
174
+
175
+
176
+ def verify_witness(ledger: Ledger, path: Path, *, ledger_path: Path | None = None) -> WitnessCheck:
177
+ """Does the ledger still contain every entry the witness recorded?"""
178
+ lines, problems = read_witness(path)
179
+ check = WitnessCheck(ok=False, problems=list(problems), lines=len(lines))
180
+ if not lines and not problems:
181
+ check.problems.append("the witness file is empty")
182
+ total = ledger.counts()["events"]
183
+ for line in lines:
184
+ entry = ledger.get_entry(f"e{line.seq}") if line.seq <= total else None
185
+ if entry is None:
186
+ check.problems.append(f"the witness recorded entry {line.seq}, but the ledger has only {total} entries: "
187
+ "the newest entries were removed")
188
+ break
189
+ if entry.hash != line.head:
190
+ check.problems.append(f"entry {line.seq} is not what the witness recorded on {safe_text(line.at, 25)}: "
191
+ "the ledger was rewritten")
192
+ break
193
+ if lines:
194
+ check.last_seq = lines[-1].seq
195
+ check.unwitnessed = max(0, total - lines[-1].seq)
196
+ if ledger_path is not None and same_disk(ledger_path, path):
197
+ check.warnings.append(SAME_DISK_WARNING)
198
+ check.ok = not check.problems
199
+ return check
@@ -0,0 +1,206 @@
1
+ Metadata-Version: 2.5
2
+ Name: memdebug
3
+ Version: 0.2.0
4
+ Summary: Inspect, compare and roll back what an AI agent's memory holds. Local-first and agent-neutral.
5
+ Project-URL: Homepage, https://github.com/juraj-jumic/memdebug
6
+ Project-URL: Source, https://github.com/juraj-jumic/memdebug
7
+ Project-URL: Issues, https://github.com/juraj-jumic/memdebug/issues
8
+ Project-URL: Changelog, https://github.com/juraj-jumic/memdebug/blob/main/CHANGELOG.md
9
+ Author: Juraj Jumić
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Keywords: ai-agents,forensics,memory,prompt-injection,security,tamper-evident
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Security
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: pydantic>=2
21
+ Requires-Dist: typer>=0.12
22
+ Provides-Extra: dev
23
+ Requires-Dist: mypy>=1.11; extra == 'dev'
24
+ Requires-Dist: pytest-xdist>=3; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Provides-Extra: mem0
28
+ Requires-Dist: mem0ai>=2; extra == 'mem0'
29
+ Provides-Extra: test-mem0
30
+ Requires-Dist: mem0ai>=2; extra == 'test-mem0'
31
+ Requires-Dist: pytest>=8; extra == 'test-mem0'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # memdebug
35
+
36
+ **See what your AI agent's memory holds, what changed, and put it back.** A local, agent-neutral tool for people who run
37
+ agents whose memory they can reach: notes in a folder or git repository, Open WebUI's memory, or a self-hosted Mem0.
38
+
39
+ An agent's memory is built from text it read at runtime, and text can be planted: an email, a web page, a document.
40
+ Nobody reviews all of it. memdebug records what the memory holds in a tamper-evident ledger, shows what changed and when,
41
+ catches edits that bypassed the store's own history, compares snapshots, flags wording worth a second look, and rolls markdown memory
42
+ back safely.
43
+
44
+ It is an **observer**: it never sits between the agent and its memory, never talks to the agent, and runs entirely on your
45
+ computer. It does not block attacks as they happen (run it next to runtime guards), and it does not yet say *which
46
+ conversation* wrote a memory. See [docs/threat-model.md](docs/threat-model.md) for exactly what it does and does not do.
47
+
48
+ > **Status: alpha (0.2).** The parts described here work. The tests run on every push on Linux, Windows and macOS (Python 3.10, 3.12 and 3.14), and
49
+ > the author also runs them on Windows 11, but expect rough edges. [ROADMAP.md](ROADMAP.md) lists what is built and what is next.
50
+
51
+ ## Try it in a minute
52
+
53
+ pip install . # needs Python 3.10+ and git 2.31+
54
+ memdebug demo # made-up agent, made-up attack, the real tools; nothing of yours is touched
55
+ memdebug demo --serve # ...and then look at it in the browser viewer
56
+
57
+ The demo plants an instruction into a note behind git's back, shows memdebug catching it, rolls the file back without losing
58
+ the planted text, and shows the ledger noticing a tampered copy. It works in a throwaway folder and removes it afterwards.
59
+
60
+ ## Watch your own agent's memory
61
+
62
+ You point memdebug at the memory; it does not hook into the agent. The easy way is the guided setup:
63
+
64
+ memdebug agents # which AI agents are on this computer, and what each keeps (looks at folder names only)
65
+ memdebug setup # finds that memory, asks before adding anything, saves a first snapshot of each
66
+ memdebug check # looks for changes once; exit code 1 means something needs a look
67
+ memdebug watch # keeps looking and says so when something changes (Ctrl+C to stop)
68
+ memdebug serve # the same story in your browser, read-only
69
+
70
+ Or register stores yourself (this is what a script would do):
71
+
72
+ memdebug add ~/agent/notes # a folder of markdown notes
73
+ memdebug add ~/agent/memory-repo # markdown notes in a git repository (also reads its history)
74
+ memdebug add --docker open-webui # Open WebUI running in Docker (memdebug copies its database itself)
75
+ memdebug add ~/copies/webui-copy.db # Open WebUI's memory, from a copy of webui.db you made yourself
76
+ memdebug add ~/.mem0/history.db --user-id me # self-hosted Mem0 (needs: pip install "mem0ai>=2")
77
+ memdebug stores | status | remove NAME
78
+
79
+ | Store | What it is | Change history | "Changed outside the history" | Rollback |
80
+ | --- | --- | --- | --- | --- |
81
+ | markdown (git) | markdown notes in a git repository | git history | an uncommitted edit | yes |
82
+ | folder | markdown notes in a plain folder (for example Claude Code's per-project memory folder) | none: changes are noticed between looks | not applicable | not yet |
83
+ | openwebui | the `memory` table of Open WebUI's `webui.db`, copied from its Docker container (or from a copy you made) | none | not applicable | no |
84
+ | mem0 | self-hosted Mem0 | Mem0's `history.db` | a change made directly in storage | no |
85
+
86
+ Which agents does it know? Claude Code, OpenClaw, Gemini CLI, Codex CLI, Windsurf and Open WebUI (in Docker): see [docs/agents.md](docs/agents.md)
87
+ for where each keeps its memory and where that was verified. Where memory sits in one file beside credentials (`~/.gemini`, `~/.codex`,
88
+ `~/.claude`), memdebug watches only that file. If Open WebUI runs in Docker, `memdebug setup` finds it and takes a read-only copy of its database before every look; there is nothing to
89
+ set up by hand (`windows-tools/copy-webui-db.ps1` is only a fallback for other setups). A store with no history can still be watched, but memdebug cannot tell an
90
+ attacker's edit from your own: it records what changed and when. ChatGPT, Claude's apps, Gemini and Copilot keep memory in the
91
+ provider's cloud: there is nothing local to watch, and memdebug says so rather than claiming to have found them.
92
+
93
+ ## Tools for people who want more
94
+
95
+ memdebug timeline | verify | diff s1 s2 --full | snapshot ... | rollback markdown --path REPO --to s1 # a dry run; add --apply
96
+ memdebug report --format markdown|json|sarif [--out FILE] [--fail-on findings|hints] # for people, programs and CI
97
+ memdebug witness --file E:\memdebug-witness.txt # a second copy of the ledger's fingerprint, kept somewhere else
98
+ memdebug verify --witness E:\memdebug-witness.txt # catches a rewritten or cut-short ledger
99
+ memdebug check --strict # also exit 1 when changed wording looks worth a second look
100
+
101
+ Where a store records it, memdebug also notes where a memory came from. For Open WebUI that is the app's own label (for example `created_by: manual`)
102
+ and whether a chat was active at the time ("consistent with being added by hand" or "a chat was active"). It is evidence, never proof, and it never
103
+ makes anything "trusted".
104
+
105
+ Hints ("worth a second look") are guesses from the wording of what was added: instructions to send something to an address, to
106
+ stop asking for confirmation, to weaken a safeguard, hidden characters, secret-looking strings. They miss things (paraphrases,
107
+ many languages) and can flag harmless text, so they never count as a verdict. A secret-looking string is never repeated in a hint.
108
+
109
+ Without `--db` the ledger and the list of stores go to a per-user folder (`%LOCALAPPDATA%\memdebug` on Windows,
110
+ `~/.local/share/memdebug` elsewhere), never the current folder, which could be inside the repository being watched. Use
111
+ `python -m memdebug ...` if the `memdebug` command is not on PATH. Treat the ledger as sensitive: it contains your agent's memory
112
+ text. Then run `memdebug selftest` once on any new machine: it proves the platform-dependent protections hold there, and says SKIP
113
+ (never PASS) for anything it could not prove.
114
+
115
+ Keeping `watch` running: on Windows, create a shortcut to `memdebug watch` in the Startup folder; on Linux or macOS use a systemd
116
+ user service or a login item. memdebug does not install anything that starts by itself.
117
+
118
+ ## Documentation
119
+
120
+ * [docs/threat-model.md](docs/threat-model.md): what is defended, against whom, and the known limits.
121
+ * [ROADMAP.md](ROADMAP.md): built, next, and not planned. [CHANGELOG.md](CHANGELOG.md): what changed. [docs/releasing.md](docs/releasing.md): how releases are made.
122
+ * [SECURITY.md](SECURITY.md): how to report a problem. [CONTRIBUTING.md](CONTRIBUTING.md): how to help.
123
+
124
+ ## The viewer
125
+
126
+ `memdebug serve` starts a read-only web page (timeline with an inspector, snapshots, compare,
127
+ integrity) on **127.0.0.1 only**, and prints a link that contains a random secret. Open that link;
128
+ the secret moves into a cookie and disappears from the address bar. It uses only Python's standard
129
+ library and sends no JavaScript at all. Run `memdebug verify` once first if the ledger is from an
130
+ older version (the viewer itself never upgrades or creates anything).
131
+
132
+ Security of the viewer, threat by threat: [docs/threat-model.md](docs/threat-model.md#the-viewer). Its limits: it is plain HTTP on your own
133
+ machine, the first link (with the secret) stays in your browser history, and processes running as you can read the secret.
134
+ Choose Auto, Light or Dark at the top right.
135
+
136
+ ## Windows
137
+
138
+ Written for Windows as well, with Windows-specific code paths. The test suite and `memdebug selftest` have been run on Windows 11, and the CI workflow covers `windows-latest`, but run `memdebug selftest` on your own machine anyway; it says SKIP, with a reason, for anything
139
+ it cannot prove (for example symlinks need Developer Mode).
140
+
141
+ - Needs Git for Windows 2.31+ (a real `git.exe`; shims and scripts are refused). `core.autocrlf=true` (the installer default)
142
+ is handled: line endings are normalised before comparing.
143
+ - git is found through PATH entries that are absolute paths only; the current folder is never searched.
144
+ - Names that mean something special on Windows (`NUL.md`, `con.md`, `file:stream.md`, trailing dots or spaces, `GIT~1`, `.GIT`)
145
+ are rejected on every platform, so the history and the files always agree.
146
+ - Directory junctions and symlinks are never followed, including by rollback. A hung git is stopped with `taskkill /T`.
147
+ - Ledger file permissions are not enforced by this tool on Windows; the default location is private to your user account.
148
+ - If git reports "dubious ownership" for a repository on another drive, fix the ownership; this tool deliberately ignores your
149
+ global `safe.directory` setting.
150
+
151
+ ## How it is built
152
+
153
+ The core (model, ledger, sync, reconcile) imports no backend. Each store type is a read-only adapter: `markdown-git` and `folder`
154
+ (markdown files), `openwebui` and `mem0` (databases). Mem0 is imported lazily in one function; the whole suite passes with Mem0 not
155
+ installed. Adding a store type is described in [CONTRIBUTING.md](CONTRIBUTING.md).
156
+
157
+ ## Development
158
+
159
+ pip install -e ".[dev]"
160
+ pytest -n auto
161
+ memdebug selftest
162
+ ruff check src tests && mypy
163
+
164
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the ground rules (everything read from a store is untrusted; adapters only read).
165
+
166
+ ## Rolling back (markdown/git)
167
+
168
+ `memdebug rollback markdown` puts a memory folder back to what a snapshot held. It is the only command that changes
169
+ your files, so it is cautious by design.
170
+
171
+ ```
172
+ memdebug rollback markdown --path C:\path\to\memory --to s1 # shows what would change; writes nothing
173
+ memdebug rollback markdown --path C:\path\to\memory --to s1 --apply # does it, after you type the snapshot id
174
+ ```
175
+
176
+ Options: `--only FILE` (repeatable) restores just those files; `--remove-added` also removes files added since the
177
+ snapshot (by default they are left alone); `--full` shows the line changes; `--yes` skips the typed confirmation.
178
+
179
+ What it guarantees:
180
+
181
+ * **Nothing is rewritten.** Files that must change in git become one new commit, written by `memdebug`, on top of
182
+ your branch. Your history stays exactly as it was.
183
+ * **Nothing is lost.** Anything git does not already hold that the rollback would replace or delete (an uncommitted
184
+ edit, a file that was never committed) is saved first under `refs/memdebug/backups/...`. Get a file back with
185
+ `git checkout <that name> -- <file>`.
186
+ * **Exact bytes.** Files come back byte for byte from the matching version in git history, line endings included. A
187
+ snapshot's normalised text is used only when git never held that version, and never if it was cut, too large or
188
+ not valid text.
189
+ * **No programs run.** Hooks, filters, fsmonitor and other helpers named in the repository are never executed.
190
+ * **It refuses unsafe states:** a detached HEAD, staged changes, a merge or rebase in progress, a git lock, links,
191
+ junctions, device names, or paths that differ only by letter case.
192
+ * **It undoes itself** if any step fails, and says so.
193
+ * **It can be undone.** It takes a snapshot just before and just after, and records a `ROLLBACK` entry in the ledger.
194
+ To undo a rollback, roll back to the "before" snapshot it printed.
195
+
196
+ `memdebug selftest` proves the "no programs run" and "exact bytes" claims on your computer.
197
+
198
+ ## Fonts
199
+
200
+ The viewer uses only fonts already on your computer: Open Sans or Noto Sans if you have them, otherwise Segoe UI
201
+ (Windows) or the system font. Memory text, which is usually markdown, is shown in the monospace font a code editor
202
+ would use (Cascadia Mono or Consolas on Windows). Nothing is downloaded or bundled.
203
+
204
+ ## License
205
+
206
+ Copyright 2026 Juraj Jumić. Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).