errlore 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (93) hide show
  1. {errlore-0.3.0 → errlore-0.3.1}/CHANGELOG.md +29 -0
  2. {errlore-0.3.0 → errlore-0.3.1}/PKG-INFO +10 -9
  3. {errlore-0.3.0 → errlore-0.3.1}/README.md +9 -8
  4. {errlore-0.3.0 → errlore-0.3.1}/pyproject.toml +1 -1
  5. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/consistency.py +30 -20
  6. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/facade.py +21 -5
  7. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/lessons/graduation.py +5 -2
  8. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/lessons/store.py +21 -4
  9. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/sanitize.py +6 -0
  10. errlore-0.3.1/tests/test_hardening.py +88 -0
  11. {errlore-0.3.0 → errlore-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  12. {errlore-0.3.0 → errlore-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  13. {errlore-0.3.0 → errlore-0.3.1}/.github/dependabot.yml +0 -0
  14. {errlore-0.3.0 → errlore-0.3.1}/.github/pull_request_template.md +0 -0
  15. {errlore-0.3.0 → errlore-0.3.1}/.github/workflows/ci.yml +0 -0
  16. {errlore-0.3.0 → errlore-0.3.1}/.github/workflows/release.yml +0 -0
  17. {errlore-0.3.0 → errlore-0.3.1}/.gitignore +0 -0
  18. {errlore-0.3.0 → errlore-0.3.1}/CONTRIBUTING.md +0 -0
  19. {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_BRIEF_FOR_ANALYSIS.md +0 -0
  20. {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_FINAL_CLOSURE.md +0 -0
  21. {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_OPEN_QUESTIONS.md +0 -0
  22. {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_Q2_CLOSURE.md +0 -0
  23. {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_REPLY_TO_P1_PROPOSAL.md +0 -0
  24. {errlore-0.3.0 → errlore-0.3.1}/FULL_DOSSIER_FOR_STRATEGIC_ANALYSIS.md +0 -0
  25. {errlore-0.3.0 → errlore-0.3.1}/LICENSE +0 -0
  26. {errlore-0.3.0 → errlore-0.3.1}/SECURITY.md +0 -0
  27. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/bench_error_reduction.py +0 -0
  28. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/bench_retrieval.py +0 -0
  29. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/CONSISTENCY_SIGNAL_2026-07-11.md +0 -0
  30. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/REPRODUCIBILITY_2026-07-11.md +0 -0
  31. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/error_reduction/report.md +0 -0
  32. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/gemma_2026-07-11_report.txt +0 -0
  33. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_2026-07-11_report.txt +0 -0
  34. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_2026-07-11_seed11.txt +0 -0
  35. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_2026-07-11_seed22.txt +0 -0
  36. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_2026-07-11_seed33.txt +0 -0
  37. {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_newfamilies_2026-07-11_report.txt +0 -0
  38. {errlore-0.3.0 → errlore-0.3.1}/docs/OBSERVABILITY_DECAY.md +0 -0
  39. {errlore-0.3.0 → errlore-0.3.1}/docs/SHADOW_MODE_SPEC.md +0 -0
  40. {errlore-0.3.0 → errlore-0.3.1}/examples/anthropic_agent.py +0 -0
  41. {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/README.md +0 -0
  42. {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/errlore_posttooluse.py +0 -0
  43. {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/errlore_posttoolusefailure.py +0 -0
  44. {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/errlore_sessionstart.py +0 -0
  45. {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/settings.json.example +0 -0
  46. {errlore-0.3.0 → errlore-0.3.1}/examples/langchain_agent.py +0 -0
  47. {errlore-0.3.0 → errlore-0.3.1}/examples/openai_agent.py +0 -0
  48. {errlore-0.3.0 → errlore-0.3.1}/integrations/openwebui/README.md +0 -0
  49. {errlore-0.3.0 → errlore-0.3.1}/integrations/openwebui/errlore_feedback_action.py +0 -0
  50. {errlore-0.3.0 → errlore-0.3.1}/integrations/openwebui/errlore_memory_filter.py +0 -0
  51. {errlore-0.3.0 → errlore-0.3.1}/site/demo.gif +0 -0
  52. {errlore-0.3.0 → errlore-0.3.1}/site/demo_script.py +0 -0
  53. {errlore-0.3.0 → errlore-0.3.1}/site/index.html +0 -0
  54. {errlore-0.3.0 → errlore-0.3.1}/site/llms.txt +0 -0
  55. {errlore-0.3.0 → errlore-0.3.1}/site/og.png +0 -0
  56. {errlore-0.3.0 → errlore-0.3.1}/site/robots.txt +0 -0
  57. {errlore-0.3.0 → errlore-0.3.1}/site/sitemap.xml +0 -0
  58. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/__init__.py +0 -0
  59. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/cli.py +0 -0
  60. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/__init__.py +0 -0
  61. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/classifier.py +0 -0
  62. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/injector.py +0 -0
  63. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/patterns.py +0 -0
  64. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/tracker.py +0 -0
  65. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/integrations/__init__.py +0 -0
  66. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/integrations/claude_code.py +0 -0
  67. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/io/__init__.py +0 -0
  68. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/io/jsonl_index.py +0 -0
  69. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/io/jsonl_writer.py +0 -0
  70. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/io/repair.py +0 -0
  71. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/lessons/__init__.py +0 -0
  72. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/lessons/models.py +0 -0
  73. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/py.typed +0 -0
  74. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/retrieval/__init__.py +0 -0
  75. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/retrieval/backend.py +0 -0
  76. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/retrieval/index.py +0 -0
  77. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/shadow.py +0 -0
  78. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/trust/__init__.py +0 -0
  79. {errlore-0.3.0 → errlore-0.3.1}/src/errlore/trust/engine.py +0 -0
  80. {errlore-0.3.0 → errlore-0.3.1}/tests/conftest.py +0 -0
  81. {errlore-0.3.0 → errlore-0.3.1}/tests/test_cli.py +0 -0
  82. {errlore-0.3.0 → errlore-0.3.1}/tests/test_consistency.py +0 -0
  83. {errlore-0.3.0 → errlore-0.3.1}/tests/test_errmem.py +0 -0
  84. {errlore-0.3.0 → errlore-0.3.1}/tests/test_facade.py +0 -0
  85. {errlore-0.3.0 → errlore-0.3.1}/tests/test_graduation.py +0 -0
  86. {errlore-0.3.0 → errlore-0.3.1}/tests/test_harm_gate.py +0 -0
  87. {errlore-0.3.0 → errlore-0.3.1}/tests/test_io.py +0 -0
  88. {errlore-0.3.0 → errlore-0.3.1}/tests/test_lessons.py +0 -0
  89. {errlore-0.3.0 → errlore-0.3.1}/tests/test_openwebui_integration.py +0 -0
  90. {errlore-0.3.0 → errlore-0.3.1}/tests/test_regressions.py +0 -0
  91. {errlore-0.3.0 → errlore-0.3.1}/tests/test_retrieval.py +0 -0
  92. {errlore-0.3.0 → errlore-0.3.1}/tests/test_shadow.py +0 -0
  93. {errlore-0.3.0 → errlore-0.3.1}/tests/test_trust.py +0 -0
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.1] - 2026-07-14
9
+
10
+ ### Security
11
+ - **Lesson `solution` is now sanitized at the injection boundary.** Previously
12
+ only the lesson *pattern* was sanitized on write; a `solution` set via
13
+ `add_lesson` (or a legacy/direct-write record) reached the prompt verbatim —
14
+ raw JSON, code fences, oversized blobs. `inject_for` now runs BOTH pattern
15
+ and solution through `sanitize_lesson_text` when assembling the block, so no
16
+ lesson can carry unsanitized content into the prompt regardless of write
17
+ path; a lesson whose pattern or solution does not survive is dropped from
18
+ that injection (and not reinforced).
19
+ - **Control characters (ANSI escapes, NUL) are stripped by the sanitizer.**
20
+ Previously only `\s` runs were collapsed, so `\x1b[…]` / `\x00` survived.
21
+
22
+ ### Fixed
23
+ - A single malformed record (non-coercible `confidence`/counter) no longer
24
+ crashes every lesson/error read — bad records are skipped with a warning
25
+ instead of taking down `inject_for`, `stats`, and `lessons()`.
26
+ - `check_consistency` clustering is now transitive (union-find) under
27
+ `similarity < 1.0`, so `distinct` / `agreement` / `stable` no longer depend
28
+ on input order. The strict default (exact match) is unchanged.
29
+
30
+ ### Changed
31
+ - `graduation.decide()` evaluates the harm survival function once instead of
32
+ twice (no behavior change; anchors still pinned by tests).
33
+
34
+ _Source: a full-project density audit (adversarial review + executable checks)
35
+ run right after 0.3.0._
36
+
8
37
  ## [0.3.0] - 2026-07-14
9
38
 
10
39
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: errlore
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Memory for AI agents that learns from failures: lessons, known-issues injection, and per-model trust — embedded, file-based, no server.
5
5
  Project-URL: Homepage, https://errlore.com
6
6
  Project-URL: Repository, https://github.com/Ma4etaSS/errlore
@@ -320,8 +320,8 @@ you only need them for advanced use.
320
320
  - All data is stored in local JSONL files in the directory you specify.
321
321
  - Nothing is sent to any server. errlore itself makes zero network calls.
322
322
  - Works fully offline -- no API keys, no accounts, no telemetry.
323
- - Files: `errors.jsonl`, `lessons.jsonl`, `injections.jsonl`, `trust.json`,
324
- `model_accuracy.jsonl`.
323
+ - Files: `errors.jsonl`, `lessons.jsonl`, `injections.jsonl`,
324
+ `counterfactuals.jsonl` (shadow mode), `trust.json`, `model_accuracy.jsonl`.
325
325
  - Sidecar files (auto-managed): `*.idx` (byte-offset index), `*.lock`
326
326
  (filelock), `vectors.npy` (embedding vectors), `vector_meta.json`
327
327
  (embedding metadata), `trust.json` (trust engine state).
@@ -334,12 +334,13 @@ prompts and reaches the model. So:
334
334
  - **Do not ingest lessons from untrusted sources without review.** Treat lesson
335
335
  capture like a code review, not like user input. A malicious lesson is a
336
336
  prompt-injection vector — and this is the real control, not the sanitizer.
337
- - **What the sanitizer does (and does not) do.** The lesson *pattern* passes
338
- `sanitize_lesson_text`: it strips raw-JSON/code-fence *noise* and caps length
339
- so log blobs don't pollute the prompt. It is a noise filter, **not** an
340
- injection defense it does not neutralize natural-language instructions, and
341
- the *solution* text is stored as you author it (so it can hold real code).
342
- Don't rely on it to make untrusted lessons safe.
337
+ - **What the sanitizer does (and does not) do.** Both the lesson *pattern* and
338
+ *solution* pass `sanitize_lesson_text` at the injection boundary: it strips
339
+ raw-JSON/code-fence *noise* and control characters (ANSI/NUL) and caps length
340
+ so log blobs don't pollute the prompt, regardless of how the lesson was
341
+ written or where it came from. It is a noise filter, **not** an injection
342
+ defense it does not neutralize natural-language instructions. Don't rely on
343
+ it to make untrusted lessons safe.
343
344
  - You control what becomes a lesson (`resolve(..., lesson=...)` /
344
345
  `add_lesson(...)`); nothing is auto-promoted from raw model output.
345
346
 
@@ -286,8 +286,8 @@ you only need them for advanced use.
286
286
  - All data is stored in local JSONL files in the directory you specify.
287
287
  - Nothing is sent to any server. errlore itself makes zero network calls.
288
288
  - Works fully offline -- no API keys, no accounts, no telemetry.
289
- - Files: `errors.jsonl`, `lessons.jsonl`, `injections.jsonl`, `trust.json`,
290
- `model_accuracy.jsonl`.
289
+ - Files: `errors.jsonl`, `lessons.jsonl`, `injections.jsonl`,
290
+ `counterfactuals.jsonl` (shadow mode), `trust.json`, `model_accuracy.jsonl`.
291
291
  - Sidecar files (auto-managed): `*.idx` (byte-offset index), `*.lock`
292
292
  (filelock), `vectors.npy` (embedding vectors), `vector_meta.json`
293
293
  (embedding metadata), `trust.json` (trust engine state).
@@ -300,12 +300,13 @@ prompts and reaches the model. So:
300
300
  - **Do not ingest lessons from untrusted sources without review.** Treat lesson
301
301
  capture like a code review, not like user input. A malicious lesson is a
302
302
  prompt-injection vector — and this is the real control, not the sanitizer.
303
- - **What the sanitizer does (and does not) do.** The lesson *pattern* passes
304
- `sanitize_lesson_text`: it strips raw-JSON/code-fence *noise* and caps length
305
- so log blobs don't pollute the prompt. It is a noise filter, **not** an
306
- injection defense it does not neutralize natural-language instructions, and
307
- the *solution* text is stored as you author it (so it can hold real code).
308
- Don't rely on it to make untrusted lessons safe.
303
+ - **What the sanitizer does (and does not) do.** Both the lesson *pattern* and
304
+ *solution* pass `sanitize_lesson_text` at the injection boundary: it strips
305
+ raw-JSON/code-fence *noise* and control characters (ANSI/NUL) and caps length
306
+ so log blobs don't pollute the prompt, regardless of how the lesson was
307
+ written or where it came from. It is a noise filter, **not** an injection
308
+ defense it does not neutralize natural-language instructions. Don't rely on
309
+ it to make untrusted lessons safe.
309
310
  - You control what becomes a lesson (`resolve(..., lesson=...)` /
310
311
  `add_lesson(...)`); nothing is auto-promoted from raw model output.
311
312
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "errlore"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Memory for AI agents that learns from failures: lessons, known-issues injection, and per-model trust — embedded, file-based, no server."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -125,11 +125,7 @@ def check_consistency(
125
125
 
126
126
  extract = _final_line if mode == "final_line" else (lambda t: t.strip())
127
127
  keys = [extract(o) for o in outputs]
128
-
129
- # Greedy clustering: an answer joins the first cluster whose representative
130
- # it is equivalent to, else it opens a new one. Counts give agreement.
131
- reps: list[str] = []
132
- counts: list[int] = []
128
+ n = len(keys)
133
129
 
134
130
  def _equivalent(a: str, b: str) -> bool:
135
131
  if a == b:
@@ -138,22 +134,36 @@ def check_consistency(
138
134
  return _word_overlap(a, b) >= similarity
139
135
  return False
140
136
 
141
- for key in keys:
142
- for i, rep in enumerate(reps):
143
- if _equivalent(key, rep):
144
- counts[i] += 1
145
- break
146
- else:
147
- reps.append(key)
148
- counts.append(1)
149
-
150
- top = max(range(len(reps)), key=lambda i: counts[i])
151
- stable = len(reps) == 1
137
+ # Union-find clustering: two answers share a cluster when they are
138
+ # pairwise equivalent, closed transitively. For the strict default
139
+ # (similarity == 1.0, exact equality) equivalence is already transitive,
140
+ # so this matches a plain set; for looser similarity it keeps `distinct`,
141
+ # `agreement`, and `stable` independent of input order.
142
+ parent = list(range(n))
143
+
144
+ def _find(x: int) -> int:
145
+ while parent[x] != x:
146
+ parent[x] = parent[parent[x]]
147
+ x = parent[x]
148
+ return x
149
+
150
+ for i in range(n):
151
+ for j in range(i + 1, n):
152
+ if _equivalent(keys[i], keys[j]):
153
+ parent[_find(i)] = _find(j)
154
+
155
+ clusters: dict[int, list[int]] = {}
156
+ for i in range(n):
157
+ clusters.setdefault(_find(i), []).append(i)
158
+
159
+ members = list(clusters.values())
160
+ largest = max(members, key=len)
161
+ stable = len(members) == 1
152
162
  return ConsistencyResult(
153
163
  stable=stable,
154
- n_runs=len(outputs),
155
- distinct=len(reps),
156
- agreement=counts[top] / len(outputs),
157
- majority=reps[top],
164
+ n_runs=n,
165
+ distinct=len(members),
166
+ agreement=len(largest) / n,
167
+ majority=keys[largest[0]],
158
168
  warning=None if stable else _UNSTABLE_WARNING,
159
169
  )
@@ -310,15 +310,31 @@ class AgentMemory:
310
310
  limit=self._max_lessons,
311
311
  exclude_quarantined=exclude_quarantined,
312
312
  )
313
- lesson_ids = [le.id for le in lessons]
314
313
 
315
- # Build text block.
314
+ # Build text block. Sanitize BOTH pattern and solution at the
315
+ # injection boundary -- this is the definitive gate, so a lesson can
316
+ # never carry raw JSON/code/control-chars into the prompt regardless of
317
+ # how it was written (add_lesson's solution, a direct store write, or
318
+ # legacy data all pass through here). A lesson whose pattern or
319
+ # solution does not survive sanitization is dropped from this
320
+ # injection, and its id is not reinforced.
316
321
  parts: list[str] = []
322
+ lesson_ids: list[str] = []
323
+ lesson_lines: list[str] = []
324
+ for le in lessons:
325
+ safe_pattern = sanitize_lesson_text(le.pattern)
326
+ safe_solution = sanitize_lesson_text(le.solution)
327
+ if safe_pattern is None or safe_solution is None:
328
+ logger.warning(
329
+ "Lesson %s dropped from injection: unsanitizable content", le.id
330
+ )
331
+ continue
332
+ lesson_lines.append(f"- {safe_pattern} -> {safe_solution}")
333
+ lesson_ids.append(le.id)
317
334
 
318
- if lessons:
335
+ if lesson_lines:
319
336
  parts.append("[LESSONS FROM PAST FAILURES]")
320
- for le in lessons:
321
- parts.append(f"- {le.pattern} -> {le.solution}")
337
+ parts.extend(lesson_lines)
322
338
 
323
339
  # Known issues (model weaknesses + past errors).
324
340
  warning = self._injector.build_warning(model, effective_task_type)
@@ -211,9 +211,12 @@ def decide(
211
211
  """
212
212
  harm_break = max(0, harm_break)
213
213
  harm_keep = max(0, harm_keep)
214
- if beta_sf(HARM_MAX, _ALPHA_H + harm_break, _BETA_H + harm_keep) > QUARANTINE_CONF:
214
+ # One survival-function evaluation drives both gates: Pr(p_h > HARM_MAX)
215
+ # for quarantine and its complement Pr(p_h <= HARM_MAX) for promote-safe.
216
+ harm_exceed = beta_sf(HARM_MAX, _ALPHA_H + harm_break, _BETA_H + harm_keep)
217
+ if harm_exceed > QUARANTINE_CONF:
215
218
  return "quarantine"
216
- safe = harm_clear_probability(harm_break, harm_keep) > PROMOTE_SAFE_CONF
219
+ safe = (1.0 - harm_exceed) > PROMOTE_SAFE_CONF
217
220
  useful = fix_useful_probability(fix_yes, fix_no) > PROMOTE_USEFUL_CONF
218
221
  if safe and useful:
219
222
  return "promote"
@@ -596,11 +596,28 @@ class LessonStore:
596
596
  # ------------------------------------------------------------------
597
597
 
598
598
  def _read_errors(self) -> list[ErrorRecord]:
599
- """Read all error records from disk."""
599
+ """Read all error records from disk, skipping malformed ones."""
600
600
  raw = self._writer.read_all(self._errors_path)
601
- return [ErrorRecord.from_dict(r) for r in raw]
601
+ out: list[ErrorRecord] = []
602
+ for r in raw:
603
+ try:
604
+ out.append(ErrorRecord.from_dict(r))
605
+ except (ValueError, TypeError):
606
+ logger.warning("Skipping malformed error record: %.80s", str(r))
607
+ return out
602
608
 
603
609
  def _read_lessons(self) -> list[Lesson]:
604
- """Read all lesson records from disk."""
610
+ """Read all lesson records from disk, skipping malformed ones.
611
+
612
+ A single record with a non-coercible field (e.g. a tampered
613
+ ``confidence`` or counter) must not crash every read path that depends
614
+ on lessons (inject_for, stats, lessons()).
615
+ """
605
616
  raw = self._writer.read_all(self._lessons_path)
606
- return [Lesson.from_dict(r) for r in raw]
617
+ out: list[Lesson] = []
618
+ for r in raw:
619
+ try:
620
+ out.append(Lesson.from_dict(r))
621
+ except (ValueError, TypeError):
622
+ logger.warning("Skipping malformed lesson record: %.80s", str(r))
623
+ return out
@@ -53,6 +53,10 @@ _JSON_LIKE_RE: re.Pattern[str] = re.compile(r"^\s*[\{\[]")
53
53
  # Collapse runs of whitespace into a single space.
54
54
  _COLLAPSE_WS_RE: re.Pattern[str] = re.compile(r"\s+")
55
55
 
56
+ # Non-printable control characters (C0 minus \t\n\r, plus DEL). These survive
57
+ # an \s-only collapse and can carry ANSI escape / NUL payloads into the prompt.
58
+ _CONTROL_CHARS_RE: re.Pattern[str] = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
59
+
56
60
 
57
61
  def _truncate_at_word(text: str, max_len: int) -> str:
58
62
  """Truncate *text* at a word boundary, appending ``...`` if needed.
@@ -92,6 +96,8 @@ def sanitize_lesson_text(text: str, *, max_len: int = 300) -> str | None:
92
96
  """
93
97
  # B8: strip BOM (UTF-8 byte-order mark) if present.
94
98
  text = text.lstrip("")
99
+ # Drop non-printable control chars (ANSI escapes, NUL) before anything else.
100
+ text = _CONTROL_CHARS_RE.sub("", text)
95
101
  text = text.strip()
96
102
  if not text:
97
103
  return None
@@ -0,0 +1,88 @@
1
+ """Regression tests for the 0.3.1 hardening pass (density audit findings)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from errlore import AgentMemory, check_consistency
9
+ from errlore.sanitize import sanitize_lesson_text
10
+
11
+
12
+ class TestSanitizerControlChars:
13
+ def test_ansi_escape_and_nul_are_stripped(self) -> None:
14
+ out = sanitize_lesson_text("do \x1b[31mred\x1b[0m and \x00 the thing")
15
+ assert out is not None
16
+ assert "\x1b" not in out and "\x00" not in out
17
+ assert "the thing" in out
18
+
19
+ def test_plain_text_unaffected(self) -> None:
20
+ assert sanitize_lesson_text("demand ISO-8601") == "demand ISO-8601"
21
+
22
+
23
+ class TestInjectionBoundarySanitizesSolution:
24
+ def test_raw_json_solution_does_not_leak(self, data_dir: Path) -> None:
25
+ mem = AgentMemory(data_dir, trust=False)
26
+ # pattern is clean; solution is a raw JSON blob (add_lesson stores it raw).
27
+ mem.add_lesson(
28
+ "extract dates", '{"secret_key": "leak-me"}', task_type="extraction"
29
+ )
30
+ inj = mem.inject_for("extract dates", "m", task_type="extraction")
31
+ # The unsanitizable solution means the lesson is dropped from injection.
32
+ assert "leak-me" not in inj.text
33
+ assert "secret_key" not in inj.text
34
+ assert inj.lesson_ids == []
35
+
36
+ def test_code_fence_stripped_from_solution(self, data_dir: Path) -> None:
37
+ mem = AgentMemory(data_dir, trust=False)
38
+ mem.add_lesson(
39
+ "validate dates",
40
+ "First run ```python\nexploit()\n``` then verify the field",
41
+ task_type="extraction",
42
+ )
43
+ inj = mem.inject_for("validate dates", "m", task_type="extraction")
44
+ assert "exploit()" not in inj.text
45
+ assert "verify the field" in inj.text
46
+
47
+ def test_control_chars_stripped_from_injected_block(self, data_dir: Path) -> None:
48
+ mem = AgentMemory(data_dir, trust=False)
49
+ mem.add_lesson("do task", "run \x1b[2J the step", task_type="t")
50
+ inj = mem.inject_for("do task", "m", task_type="t")
51
+ assert "\x1b" not in inj.text
52
+ assert "the step" in inj.text
53
+
54
+
55
+ class TestMalformedRecordDoesNotCrashReads:
56
+ def test_bad_record_is_skipped(self, data_dir: Path) -> None:
57
+ mem = AgentMemory(data_dir, trust=False)
58
+ mem.add_lesson("good pattern", "good solution", task_type="t")
59
+ # Append a poisoned record with a non-coercible confidence value.
60
+ bad = {
61
+ "id": "badrec00",
62
+ "pattern": "p",
63
+ "solution": "s",
64
+ "confidence": "NOT_A_FLOAT",
65
+ }
66
+ with (Path(data_dir) / "lessons.jsonl").open("a", encoding="utf-8") as f:
67
+ f.write(json.dumps(bad) + "\n")
68
+ # Reads must not raise; the good lesson survives, the bad one is dropped.
69
+ lessons = mem.lessons()
70
+ assert [le.pattern for le in lessons] == ["good pattern"]
71
+ # stats() (which also reads lessons) must not crash either.
72
+ assert mem.stats()["lessons_total"] == 1
73
+
74
+
75
+ class TestConsistencyClusteringOrderIndependence:
76
+ def test_loose_similarity_is_transitive_and_order_independent(self) -> None:
77
+ # Chain: "x y" ~ "y z" ~ "z w" pairwise (overlap 0.5), transitively one.
78
+ forward = check_consistency(["x y", "y z", "z w"], similarity=0.5)
79
+ reverse = check_consistency(["z w", "y z", "x y"], similarity=0.5)
80
+ assert forward.distinct == reverse.distinct == 1
81
+ assert forward.stable is reverse.stable is True
82
+ assert forward.agreement == reverse.agreement == 1.0
83
+
84
+ def test_strict_default_unchanged(self) -> None:
85
+ r = check_consistency(["a", "a", "b"])
86
+ assert r.distinct == 2
87
+ assert r.stable is False
88
+ assert r.agreement == 2 / 3
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes