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.
memdebug/report.py ADDED
@@ -0,0 +1,279 @@
1
+ """Reports on a ledger: for a person (Markdown), for a program (JSON) and for CI and code-scanning dashboards (SARIF).
2
+
3
+ Everything that came from a memory store is untrusted text. In Markdown it only ever appears inside a code fence that
4
+ is longer than any run of backticks in the text, so it cannot turn into headings, links or HTML. Control and
5
+ bidirectional characters are made visible. In JSON and SARIF it is a plain string value, bounded in length.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import secrets
13
+ import stat
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from urllib.parse import quote
17
+
18
+ from . import __version__
19
+ from .describe import rollback_details
20
+ from .errors import MemdebugError
21
+ from .hints import new_hints
22
+ from .ledger import Ledger
23
+ from .models import Op, Trust
24
+ from .textsafe import safe_text
25
+ from .witness import WitnessCheck
26
+
27
+ MAX_FINDINGS = 500
28
+ MAX_TEXT = 1500
29
+
30
+ RULES = {
31
+ "outside-history": ("A memory changed outside its store's own history", "warning",
32
+ "A memory was added, changed or removed without going through the store's own history (an uncommitted file "
33
+ "edit, a direct database change). Check whether you made the change."),
34
+ "untrusted-source": ("A memory came from an untrusted source", "warning",
35
+ "The recorded source of this memory is an untrusted one (for example a tool result the agent read)."),
36
+ "integrity": ("The ledger failed its integrity check", "error",
37
+ "The ledger's hash chain or a snapshot does not match what was recorded. Do not trust it until this is understood."),
38
+ "hint": ("Worth a second look", "note",
39
+ "The wording of what was added looks like a known poisoning pattern (an instruction to send data to an address, to stop "
40
+ "asking for confirmation, to weaken a safeguard, hidden characters, or a secret). A heuristic: it misses things and can be wrong."),
41
+ "witness": ("The ledger disagrees with its witness", "error",
42
+ "The ledger was rewritten or cut short compared with the witness file."),
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class Finding:
48
+ rule: str
49
+ level: str
50
+ message: str
51
+ memory_id: str | None = None
52
+ event_id: str | None = None
53
+ when: str | None = None
54
+ before: str | None = None
55
+ after: str | None = None
56
+ evidence: str | None = None
57
+
58
+
59
+ @dataclass
60
+ class Report:
61
+ generated_at: str
62
+ tool_version: str
63
+ ledger_name: str
64
+ integrity_ok: bool
65
+ integrity_problems: list[str]
66
+ witness: WitnessCheck | None
67
+ counts: dict
68
+ findings: list[Finding] = field(default_factory=list)
69
+ rollbacks: list[dict] = field(default_factory=list)
70
+ snapshots: list[dict] = field(default_factory=list)
71
+ truncated: bool = False
72
+
73
+ @property
74
+ def hinted(self) -> bool:
75
+ return any(f.rule == "hint" for f in self.findings)
76
+
77
+ @property
78
+ def attention(self) -> bool:
79
+ return (not self.integrity_ok or (self.witness is not None and not self.witness.ok)
80
+ or any(f.level in ("warning", "error") for f in self.findings))
81
+
82
+
83
+ def _text(value: str | None) -> str | None:
84
+ """Memory text, bounded, with control and bidi characters made visible; line breaks are kept."""
85
+ if value is None:
86
+ return None
87
+ lines = value.replace("\r\n", "\n").split("\n")
88
+ out = "\n".join(safe_text(line, None) for line in lines)
89
+ return out if len(out) <= MAX_TEXT else out[:MAX_TEXT] + f"\n... [{len(out) - MAX_TEXT} more characters]"
90
+
91
+
92
+ def build_report(ledger: Ledger, *, ledger_name: str = "ledger", witness: WitnessCheck | None = None,
93
+ now: datetime | None = None) -> Report:
94
+ verdict = ledger.verify()
95
+ counts = ledger.counts()
96
+ report = Report(
97
+ generated_at=(now or datetime.now(timezone.utc)).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
98
+ tool_version=__version__, ledger_name=safe_text(ledger_name, 80), integrity_ok=verdict.ok,
99
+ integrity_problems=[safe_text(p, 300) for p in verdict.problems[:50]], witness=witness,
100
+ counts={"events": counts["events"], "snapshots": counts["snapshots"], "outside_history": counts["by_op"].get("EXTERNAL", 0),
101
+ "untrusted": counts["untrusted"], "rollbacks": counts["by_op"].get("ROLLBACK", 0), "hints": 0},
102
+ )
103
+ for entry in ledger.entries():
104
+ event = entry.event
105
+ when = event.ts.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
106
+ if event.op == Op.EXTERNAL and len(report.findings) < MAX_FINDINGS:
107
+ report.findings.append(Finding("outside-history", "warning",
108
+ f"{safe_text(event.memory_id, 80)} changed outside the store's own history",
109
+ safe_text(event.memory_id, 120), entry.id, when, _text(event.before), _text(event.after)))
110
+ elif event.trust == Trust.UNTRUSTED and event.op not in (Op.SNAPSHOT, Op.SNAPSHOT_DELETED, Op.ROLLBACK) and len(report.findings) < MAX_FINDINGS:
111
+ report.findings.append(Finding("untrusted-source", "warning",
112
+ f"{safe_text(event.memory_id, 80)} came from an untrusted source",
113
+ safe_text(event.memory_id, 120), entry.id, when, _text(event.before), _text(event.after)))
114
+ if event.op in (Op.ADD, Op.UPDATE, Op.EXTERNAL):
115
+ for hint in [h for h in new_hints(event.before, event.after, budget=0.1, max_chars=20_000) if h.severity == "warning"][:3]:
116
+ if len(report.findings) < MAX_FINDINGS:
117
+ report.findings.append(Finding("hint", "note", hint.message, safe_text(event.memory_id, 120), entry.id, when, evidence=hint.evidence))
118
+ if event.op == Op.ROLLBACK:
119
+ details = rollback_details(event)
120
+ if details is not None:
121
+ report.rollbacks.append({"id": entry.id, "rollback": event.memory_id, "when": when, "target": details["target"],
122
+ "files": details["file_count"], "undo_point": details["before_snapshot"],
123
+ "commit": details["commit"], "backup": details["backup"]})
124
+ report.truncated = len(report.findings) >= MAX_FINDINGS
125
+ report.counts["hints"] = sum(1 for f in report.findings if f.rule == "hint")
126
+ for info in ledger.list_snapshots():
127
+ report.snapshots.append({"id": info.id, "taken": info.taken_at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
128
+ "memories": info.count, "label": safe_text(info.label or "", 100), "complete": info.complete})
129
+ if not verdict.ok:
130
+ report.findings.insert(0, Finding("integrity", "error", "the ledger failed its integrity check: " + (report.integrity_problems[0] if report.integrity_problems else "")))
131
+ if witness is not None and not witness.ok:
132
+ report.findings.insert(0, Finding("witness", "error", "the ledger disagrees with its witness: " + safe_text(witness.problems[0] if witness.problems else "", 300)))
133
+ return report
134
+
135
+
136
+ # -- Markdown -------------------------------------------------------------------------------------------------------
137
+
138
+ def _fence(text: str) -> str:
139
+ longest = max((len(m.group(0)) for m in re.finditer(r"`+", text)), default=0)
140
+ return "`" * max(3, longest + 1)
141
+
142
+
143
+ def _code_block(text: str | None) -> str:
144
+ if text is None:
145
+ return "_(none)_\n"
146
+ fence = _fence(text)
147
+ return f"{fence}text\n{text}\n{fence}\n"
148
+
149
+
150
+ def _code_span(text: str) -> str:
151
+ longest = max((len(m.group(0)) for m in re.finditer(r"`+", text)), default=0)
152
+ ticks = "`" * (longest + 1)
153
+ pad = " " if text.startswith("`") or text.endswith("`") else ""
154
+ return f"{ticks}{pad}{text}{pad}{ticks}"
155
+
156
+
157
+ def to_markdown(report: Report) -> str:
158
+ out = ["# memdebug report", "",
159
+ f"Generated {report.generated_at} by memdebug {report.tool_version}. Ledger: {_code_span(report.ledger_name)}.", ""]
160
+ verdict = "INTACT" if report.integrity_ok else "PROBLEMS FOUND"
161
+ out += ["## Summary", "",
162
+ f"- Ledger integrity: **{verdict}**",
163
+ f"- Entries: {report.counts['events']}, snapshots: {report.counts['snapshots']}, rollbacks: {report.counts['rollbacks']}",
164
+ f"- Changes outside the store's own history: **{report.counts['outside_history']}**",
165
+ f"- Memories from an untrusted source: {report.counts['untrusted']}",
166
+ f"- Wording worth a second look (heuristic hints): {report.counts['hints']}"]
167
+ if report.witness is not None:
168
+ w = report.witness
169
+ out.append(f"- Witness: **{'agrees' if w.ok else 'DISAGREES'}** ({w.lines} witnessed head(s), {w.unwitnessed} newer entries not yet witnessed)")
170
+ if not report.integrity_ok:
171
+ out += ["", "### Integrity problems", ""] + [f"- {_code_span(p)}" for p in report.integrity_problems]
172
+ out += ["", "## Findings", ""]
173
+ if not report.findings:
174
+ out.append("Nothing needs a look.")
175
+ for number, f in enumerate(report.findings, 1):
176
+ out += [f"### {number}. {f.rule} ({f.level})", "", f"{_code_span(f.message)}", ""]
177
+ if f.memory_id:
178
+ out.append(f"- Memory: {_code_span(f.memory_id)}")
179
+ if f.event_id:
180
+ out.append(f"- Ledger entry: {_code_span(f.event_id)}, noticed {f.when}")
181
+ if f.evidence:
182
+ out.append(f"- Evidence: {_code_span(f.evidence)}")
183
+ if f.before is not None or f.after is not None:
184
+ out += ["", "Before:", "", _code_block(f.before), "After:", "", _code_block(f.after)]
185
+ out.append("")
186
+ if report.truncated:
187
+ out.append(f"_Only the first {MAX_FINDINGS} findings are shown._\n")
188
+ if report.rollbacks:
189
+ out += ["## Rollbacks", ""]
190
+ for r in report.rollbacks:
191
+ out.append(f"- {_code_span(r['rollback'])} at {r['when']}: back to {_code_span(r['target'])}, {r['files']} file(s); "
192
+ f"undo point {_code_span(r['undo_point'] or '-')}")
193
+ out.append("")
194
+ if report.snapshots:
195
+ out += ["## Snapshots", ""]
196
+ for s in report.snapshots:
197
+ out.append(f"- {_code_span(s['id'])} {s['taken']}: {s['memories']} memories" + (f", {_code_span(s['label'])}" if s["label"] else ""))
198
+ out.append("")
199
+ out += ["---", "memdebug records what a memory store holds and what changed. It does not say who or what caused a change, and "
200
+ "it is not a prompt-injection detector. See docs/threat-model.md.", ""]
201
+ return "\n".join(out)
202
+
203
+
204
+ # -- JSON -----------------------------------------------------------------------------------------------------------
205
+
206
+ def to_dict(report: Report) -> dict:
207
+ return {
208
+ "tool": {"name": "memdebug", "version": report.tool_version}, "generated_at": report.generated_at, "ledger": report.ledger_name,
209
+ "integrity": {"ok": report.integrity_ok, "problems": report.integrity_problems},
210
+ "witness": None if report.witness is None else {"ok": report.witness.ok, "problems": [safe_text(p, 300) for p in report.witness.problems],
211
+ "lines": report.witness.lines, "unwitnessed": report.witness.unwitnessed},
212
+ "counts": report.counts, "attention": report.attention, "truncated": report.truncated,
213
+ "findings": [{"rule": f.rule, "level": f.level, "message": f.message, "memory_id": f.memory_id, "ledger_entry": f.event_id,
214
+ "noticed": f.when, "before": f.before, "after": f.after, "evidence": f.evidence} for f in report.findings],
215
+ "rollbacks": report.rollbacks, "snapshots": report.snapshots,
216
+ }
217
+
218
+
219
+ def to_json(report: Report) -> str:
220
+ return json.dumps(to_dict(report), indent=2, ensure_ascii=True) + "\n"
221
+
222
+
223
+ # -- SARIF ----------------------------------------------------------------------------------------------------------
224
+
225
+ _PATHLIKE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-/ ]{0,200}\.[A-Za-z0-9]{1,8}\Z") # a file name with an extension, not just any id
226
+
227
+
228
+ def to_sarif(report: Report) -> str:
229
+ rules = [{"id": rid, "name": rid, "shortDescription": {"text": short}, "fullDescription": {"text": full},
230
+ "defaultConfiguration": {"level": level}} for rid, (short, level, full) in RULES.items()]
231
+ results = []
232
+ for f in report.findings:
233
+ item: dict = {"ruleId": f.rule, "level": f.level, "message": {"text": f.message},
234
+ "properties": {"ledger_entry": f.event_id, "noticed": f.when, "evidence": f.evidence}}
235
+ if f.memory_id:
236
+ location: dict = {"logicalLocations": [{"name": f.memory_id, "kind": "memory"}]}
237
+ if _PATHLIKE.match(f.memory_id) and ".." not in f.memory_id.split("/"):
238
+ location["physicalLocation"] = {"artifactLocation": {"uri": quote(f.memory_id, safe="/")}}
239
+ item["locations"] = [location]
240
+ results.append(item)
241
+ document = {"$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0",
242
+ "runs": [{"tool": {"driver": {"name": "memdebug", "version": report.tool_version, "rules": rules}}, "results": results}]}
243
+ return json.dumps(document, indent=2, ensure_ascii=True) + "\n"
244
+
245
+
246
+ RENDERERS = {"markdown": to_markdown, "json": to_json, "sarif": to_sarif}
247
+
248
+
249
+ def write_report(path, text: str, *, force: bool = False) -> None:
250
+ """Write a report file. It contains memory text, so it is created private; an existing file is only replaced
251
+ with `force`, and links and folders are refused."""
252
+ from pathlib import Path
253
+
254
+ target = Path(path)
255
+ try:
256
+ info = os.lstat(target)
257
+ except FileNotFoundError:
258
+ info = None
259
+ if info is not None:
260
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
261
+ raise MemdebugError("the report path must be a plain file, not a link or a folder")
262
+ if not force:
263
+ raise MemdebugError("that file already exists; use --force to replace it")
264
+ if not target.parent.is_dir():
265
+ raise MemdebugError("the folder for the report does not exist")
266
+ temporary = target.parent / f".memdebug-report-{secrets.token_hex(6)}.tmp"
267
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600)
268
+ try:
269
+ with os.fdopen(fd, "wb") as handle:
270
+ handle.write(text.encode("utf-8"))
271
+ handle.flush()
272
+ os.fsync(handle.fileno())
273
+ os.replace(temporary, target)
274
+ except BaseException:
275
+ try:
276
+ os.unlink(temporary)
277
+ except OSError:
278
+ pass
279
+ raise
@@ -0,0 +1,69 @@
1
+ """The steps around a markdown rollback, shared by the command line and the demo.
2
+
3
+ Restorer (adapters/restore.py) changes the files. This module wraps it so that a rollback is always recorded the same
4
+ way: the state before is snapshotted (that snapshot is the undo point), the files are restored, the state after is
5
+ snapshotted, and a ROLLBACK entry is chained into the ledger. The ledger record is checked BEFORE any file is touched,
6
+ so recording can never fail after the damage is done.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from typing import Callable
13
+
14
+ from .adapters.restore import Outcome, Plan, Restorer
15
+ from .errors import MemdebugError
16
+ from .ledger import MAX_ROLLBACK_FILES, Ledger, validate_rollback_details
17
+ from .models import LedgerEntry, Snapshot, SnapshotInfo
18
+ from .sync import sync
19
+ from .textsafe import safe_text
20
+
21
+
22
+ @dataclass
23
+ class RollbackResult:
24
+ outcome: Outcome
25
+ before: SnapshotInfo # the undo point
26
+ after: SnapshotInfo | None = None
27
+ entry: LedgerEntry | None = None # None when recording failed (see record_error)
28
+ record_error: str | None = None # the files WERE restored, but the ledger could not record it
29
+
30
+
31
+ def rollback_details(snapshot: Snapshot, plan: Plan, before_id: str | None, after_id: str | None,
32
+ outcome: Outcome | None = None) -> dict:
33
+ items = outcome.items if outcome else plan.items
34
+ return {"target": snapshot.info.id, "before_snapshot": before_id, "after_snapshot": after_id,
35
+ "commit": outcome.commit if outcome else None, "previous_head": plan.head,
36
+ "backup": outcome.backup_ref if outcome else None, "file_count": len(items),
37
+ "files": [{"path": i.path, "action": i.action, "source": i.source} for i in items[:MAX_ROLLBACK_FILES]]}
38
+
39
+
40
+ def run_rollback(adapter, ledger: Ledger, scope: dict[str, str], restorer: Restorer, snapshot: Snapshot, plan: Plan, *,
41
+ only: list[str] | None = None, remove_added: bool = False, settle: float = 1.0,
42
+ warn: Callable[[str], None] = lambda text: None) -> RollbackResult:
43
+ """Carry out a confirmed plan. Raises MemdebugError if it could not be started or the files could not be restored."""
44
+
45
+ def record_state(note: str, written: dict | None = None) -> SnapshotInfo:
46
+ report = sync(adapter, ledger, scope, settle_seconds=settle, acknowledge=written)
47
+ for warning in report.warnings:
48
+ warn(safe_text(warning, 300))
49
+ if report.live is None:
50
+ raise MemdebugError("the sync did not produce a listing to snapshot")
51
+ return ledger.save_snapshot(adapter.name, scope, report.live.memories, complete=report.live.complete,
52
+ taken_at=datetime.now(timezone.utc), label=note)
53
+
54
+ try: # the record is checked now, so recording can never fail after the files have been changed
55
+ validate_rollback_details(rollback_details(snapshot, plan, None, None))
56
+ except ValueError as exc:
57
+ raise MemdebugError(f"this rollback could not be recorded ({safe_text(exc, 100)}), so it was not started") from exc
58
+
59
+ before = record_state(f"before rollback to {snapshot.info.id}") # this is what undoes the rollback
60
+ outcome = restorer.apply(snapshot, expected_plan_id=plan.plan_id, only=list(only or []), remove_added=remove_added)
61
+ result = RollbackResult(outcome=outcome, before=before)
62
+ try:
63
+ result.after = record_state(f"after rollback to {snapshot.info.id}",
64
+ {i.path: (None if i.action == "remove" else i.target_text) for i in outcome.items})
65
+ result.entry = ledger.record_rollback(adapter.name, scope, rollback_details(snapshot, plan, before.id, result.after.id, outcome),
66
+ ts=datetime.now(timezone.utc))
67
+ except MemdebugError as exc:
68
+ result.record_error = safe_text(exc, 300)
69
+ return result