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.
- {errlore-0.3.0 → errlore-0.3.1}/CHANGELOG.md +29 -0
- {errlore-0.3.0 → errlore-0.3.1}/PKG-INFO +10 -9
- {errlore-0.3.0 → errlore-0.3.1}/README.md +9 -8
- {errlore-0.3.0 → errlore-0.3.1}/pyproject.toml +1 -1
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/consistency.py +30 -20
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/facade.py +21 -5
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/lessons/graduation.py +5 -2
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/lessons/store.py +21 -4
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/sanitize.py +6 -0
- errlore-0.3.1/tests/test_hardening.py +88 -0
- {errlore-0.3.0 → errlore-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/.github/dependabot.yml +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/.github/pull_request_template.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/.github/workflows/ci.yml +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/.github/workflows/release.yml +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/.gitignore +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/CONTRIBUTING.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_BRIEF_FOR_ANALYSIS.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_FINAL_CLOSURE.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_OPEN_QUESTIONS.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_Q2_CLOSURE.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/ERRLORE_REPLY_TO_P1_PROPOSAL.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/FULL_DOSSIER_FOR_STRATEGIC_ANALYSIS.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/LICENSE +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/SECURITY.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/bench_error_reduction.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/bench_retrieval.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/CONSISTENCY_SIGNAL_2026-07-11.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/REPRODUCIBILITY_2026-07-11.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/error_reduction/report.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/gemma_2026-07-11_report.txt +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_2026-07-11_report.txt +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_2026-07-11_seed11.txt +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_2026-07-11_seed22.txt +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_2026-07-11_seed33.txt +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/benchmarks/results/haiku_newfamilies_2026-07-11_report.txt +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/docs/OBSERVABILITY_DECAY.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/docs/SHADOW_MODE_SPEC.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/examples/anthropic_agent.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/README.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/errlore_posttooluse.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/errlore_posttoolusefailure.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/errlore_sessionstart.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/examples/claude-code/settings.json.example +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/examples/langchain_agent.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/examples/openai_agent.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/integrations/openwebui/README.md +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/integrations/openwebui/errlore_feedback_action.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/integrations/openwebui/errlore_memory_filter.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/site/demo.gif +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/site/demo_script.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/site/index.html +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/site/llms.txt +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/site/og.png +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/site/robots.txt +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/site/sitemap.xml +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/__init__.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/cli.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/__init__.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/classifier.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/injector.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/patterns.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/errmem/tracker.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/integrations/__init__.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/integrations/claude_code.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/io/__init__.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/io/jsonl_index.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/io/jsonl_writer.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/io/repair.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/lessons/__init__.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/lessons/models.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/py.typed +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/retrieval/__init__.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/retrieval/backend.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/retrieval/index.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/shadow.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/trust/__init__.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/src/errlore/trust/engine.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/conftest.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_cli.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_consistency.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_errmem.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_facade.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_graduation.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_harm_gate.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_io.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_lessons.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_openwebui_integration.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_regressions.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_retrieval.py +0 -0
- {errlore-0.3.0 → errlore-0.3.1}/tests/test_shadow.py +0 -0
- {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.
|
|
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`,
|
|
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.**
|
|
338
|
-
`sanitize_lesson_text
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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`,
|
|
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.**
|
|
304
|
-
`sanitize_lesson_text
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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.
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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=
|
|
155
|
-
distinct=len(
|
|
156
|
-
agreement=
|
|
157
|
-
majority=
|
|
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
|
|
335
|
+
if lesson_lines:
|
|
319
336
|
parts.append("[LESSONS FROM PAST FAILURES]")
|
|
320
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|