fix(evidence): make result summaries safe
This commit is contained in:
@@ -170,3 +170,19 @@ and fail-closed tests, real Docker lifecycle, scoped Ruff/diff, and the full har
|
|||||||
|
|
||||||
Focused tests and scoped Ruff/diff pass. The contemporaneous full suite reaches an unrelated Task 6
|
Focused tests and scoped Ruff/diff pass. The contemporaneous full suite reaches an unrelated Task 6
|
||||||
DWH snapshot fixture missing its newly required workspace identity.
|
DWH snapshot fixture missing its newly required workspace identity.
|
||||||
|
|
||||||
|
### Safe result representation and exact text totals
|
||||||
|
|
||||||
|
- `PipelineResult.manifest` is explicitly excluded from dataclass representation and the custom
|
||||||
|
representation is fixed-size operational data only. It omits manifest ids, documents, chunks,
|
||||||
|
content, metadata, and errors; `str(result)` inherits the same safe representation.
|
||||||
|
- Text-mode Evidence success output reads the uncapped aggregate totals from `payload["counts"]`
|
||||||
|
rather than the intentionally capped identifier arrays.
|
||||||
|
- Regression coverage builds a thousand-document/chunk manifest containing content and
|
||||||
|
credential-like metadata secrets, checks bounded `repr`/`str`, and verifies exact totals above
|
||||||
|
the 100-item public-array cap.
|
||||||
|
|
||||||
|
Focused Evidence verification passes (`67 passed`), and scoped Ruff is clean. The full harness run
|
||||||
|
is not green in this sandbox: Docker-backed tests cannot access the daemon, wheel packaging cannot
|
||||||
|
use the restricted build environment, and concurrent Task 6 DWH binding changes currently fail two
|
||||||
|
DWH tests. None of those failures touch the Evidence files in this follow-up.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
from tht.corpus.chunk import ChunkPolicy
|
from tht.corpus.chunk import ChunkPolicy
|
||||||
from tht.corpus.pipeline import CorpusPipeline, PipelineError, PipelineResult
|
from tht.corpus.pipeline import CorpusPipeline, PipelineError, PipelineResult
|
||||||
from tht.corpus.store import CorpusStore
|
from tht.corpus.store import CorpusStore
|
||||||
from tht.corpus.models import CanonicalChunk, CorpusManifest
|
from tht.corpus.models import CanonicalChunk, CanonicalDocument, CorpusManifest
|
||||||
from tht.ports.evidence import AcquiredDocument, SourceObject
|
from tht.ports.evidence import AcquiredDocument, SourceObject
|
||||||
from tht.ports.vector import VectorCapabilities, VectorHealth
|
from tht.ports.vector import VectorCapabilities, VectorHealth
|
||||||
|
|
||||||
@@ -362,6 +362,30 @@ def test_pipeline_result_public_dump_is_bounded_and_excludes_evidence_content(tm
|
|||||||
assert len(json.dumps(large)) < 25_000
|
assert len(json.dumps(large)) < 25_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_result_repr_is_bounded_and_excludes_manifest_secrets():
|
||||||
|
secret = "TOP_SECRET_CONTENT"
|
||||||
|
manifest = CorpusManifest.model_construct(
|
||||||
|
manifest_id="gen:" + "a" * 64,
|
||||||
|
documents=tuple(CanonicalDocument.model_construct(content=secret) for _ in range(1000)),
|
||||||
|
chunks=tuple(CanonicalChunk.model_construct(content=secret) for _ in range(1000)),
|
||||||
|
metadata={"password": secret, "credential": "Bearer " + secret},
|
||||||
|
)
|
||||||
|
result = PipelineResult(
|
||||||
|
"succeeded", "gen:" + "a" * 64, True, (), (), (), manifest,
|
||||||
|
run_id="b" * 32,
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = repr(result)
|
||||||
|
assert str(result) == rendered
|
||||||
|
assert len(rendered) < 1000
|
||||||
|
assert secret not in rendered
|
||||||
|
assert "password" not in rendered
|
||||||
|
assert "credential" not in rendered
|
||||||
|
assert "manifest" not in rendered.lower()
|
||||||
|
assert "documents" not in rendered
|
||||||
|
assert "chunks" not in rendered
|
||||||
|
|
||||||
|
|
||||||
def test_reused_corpus_root_rejects_workspace_rename_before_any_mutation(tmp_path):
|
def test_reused_corpus_root_rejects_workspace_rename_before_any_mutation(tmp_path):
|
||||||
vectors = Vectors()
|
vectors = Vectors()
|
||||||
first = pipeline(tmp_path, Source([(item("one", "a"), "stable")]), vectors=vectors)
|
first = pipeline(tmp_path, Source([(item("one", "a"), "stable")]), vectors=vectors)
|
||||||
|
|||||||
@@ -72,6 +72,27 @@ def test_preprocess_real_failed_stage_result_exits_nonzero(monkeypatch, tmp_path
|
|||||||
assert "secret" not in response.output
|
assert "secret" not in response.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_preprocess_evidence_text_uses_uncapped_result_counts(monkeypatch, tmp_path):
|
||||||
|
import tht.cli.preprocess_cmd as command
|
||||||
|
|
||||||
|
result = SimpleNamespace(model_dump=lambda mode=None: {
|
||||||
|
"status": "succeeded", "run_id": "a" * 32,
|
||||||
|
"generation": "gen:" + "b" * 64, "published": True,
|
||||||
|
"changed": ["fs:item"] * 100,
|
||||||
|
"unchanged": ["fs:item"] * 100,
|
||||||
|
"removed": ["fs:item"] * 100,
|
||||||
|
"counts": {"changed": 1001, "unchanged": 902, "removed": 803},
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(command, "run_from_config", lambda *args, **kwargs: result)
|
||||||
|
|
||||||
|
response = CliRunner().invoke(
|
||||||
|
app, ["preprocess", "evidence", "-c", str(tmp_path / "workspace.yaml")]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.exit_code == 0, response.output
|
||||||
|
assert "changed=1001 unchanged=902 removed=803" in response.output
|
||||||
|
|
||||||
|
|
||||||
def test_preprocess_resume_rejects_generation_id_before_configuration(monkeypatch, tmp_path):
|
def test_preprocess_resume_rejects_generation_id_before_configuration(monkeypatch, tmp_path):
|
||||||
import tht.cli.preprocess_cmd as command
|
import tht.cli.preprocess_cmd as command
|
||||||
|
|
||||||
|
|||||||
@@ -172,9 +172,11 @@ def evidence_cmd(
|
|||||||
if json_output:
|
if json_output:
|
||||||
typer.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
typer.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||||
else:
|
else:
|
||||||
|
counts = payload["counts"]
|
||||||
typer.echo(
|
typer.echo(
|
||||||
f"OK: run={payload['run_id']} generation={payload['generation']} changed={len(payload['changed'])} "
|
f"OK: run={payload['run_id']} generation={payload['generation']} "
|
||||||
f"unchanged={len(payload['unchanged'])} removed={len(payload['removed'])}"
|
f"changed={counts['changed']} unchanged={counts['unchanged']} "
|
||||||
|
f"removed={counts['removed']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -45,10 +45,22 @@ class PipelineResult:
|
|||||||
changed: tuple[str, ...]
|
changed: tuple[str, ...]
|
||||||
unchanged: tuple[str, ...]
|
unchanged: tuple[str, ...]
|
||||||
removed: tuple[str, ...]
|
removed: tuple[str, ...]
|
||||||
manifest: CorpusManifest
|
manifest: CorpusManifest = field(repr=False)
|
||||||
run_id: str | None = None
|
run_id: str | None = None
|
||||||
resumed_from: str | None = None
|
resumed_from: str | None = None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
counts = {
|
||||||
|
"changed": len(self.changed),
|
||||||
|
"unchanged": len(self.unchanged),
|
||||||
|
"removed": len(self.removed),
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
f"PipelineResult(status={self.status!r}, generation={self.generation!r}, "
|
||||||
|
f"published={self.published!r}, counts={counts!r}, "
|
||||||
|
f"run_id={self.run_id!r}, resumed_from={self.resumed_from!r})"
|
||||||
|
)
|
||||||
|
|
||||||
def model_dump(self, mode=None):
|
def model_dump(self, mode=None):
|
||||||
def bounded(values: tuple[str, ...]) -> list[str]:
|
def bounded(values: tuple[str, ...]) -> list[str]:
|
||||||
return [value[:200] for value in values[:100]]
|
return [value[:200] for value in values[:100]]
|
||||||
|
|||||||
Reference in New Issue
Block a user