Skip to content

Commit 63eea98

Browse files
authored
Merge pull request #644 from kai392/fix/critical-issue-zero-tail-returns-all
fix(audit): kb.audit with tail=0 returns the entire log instead of nothing
2 parents 0d63d9e + cd0b77d commit 63eea98

8 files changed

Lines changed: 77 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ All notable changes to vouch are documented here. Format follows
9494
`prompt_gate_cfg` still used bare `bool()`, so a quoted
9595
`enabled: "false"` silently turned those features *on*. both now
9696
use `coerce_bool`.
97+
- **a zero `tail` on `kb.audit` returns no events, not every event**: the
98+
window was `events[-tail:]`, and `-0` is `0`, so asking for zero events
99+
sliced from the start and handed back the whole visible log — a negative
100+
tail dropped that many off the front and returned the rest. all three
101+
surfaces (mcp, jsonl, cli) now share `audit.tail_events`, so the clamp
102+
cannot drift between them. `retrieval_events.read_events` carried the same
103+
`[-limit:]` boundary and is fixed with it.
97104
- **digest drops archived followup pages** (#625):
98105
`followups_due` already skipped `done`/`dropped` metadata, but an
99106
`ARCHIVED` page with `followup_status=open` and a past `due_at` still

src/vouch/audit.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,18 @@ def read_events(
240240
yield event
241241

242242

243+
def tail_events(events: list[AuditEvent], tail: int) -> list[AuditEvent]:
244+
"""The newest ``tail`` events; none when ``tail`` is not positive.
245+
246+
``events[-tail:]`` is the obvious spelling and is wrong at the boundary:
247+
``-0`` is ``0``, so a zero tail slices from the start and hands back the
248+
whole log — the opposite of the bound the caller asked for. A negative tail
249+
is worse, dropping that many events off the front and returning the rest.
250+
Shared by the three ``kb.audit`` surfaces so the clamp cannot drift.
251+
"""
252+
return events[-tail:] if tail > 0 else []
253+
254+
243255
def count_events(kb_dir: Path) -> int:
244256
path = _audit_path(kb_dir)
245257
if not path.exists():

src/vouch/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3985,7 +3985,9 @@ def audit(tail: int, as_json: bool, project: str | None, agent: str | None) -> N
39853985
project=project,
39863986
agent=agent,
39873987
)
3988-
events = list(audit_mod.read_events(store.kb_dir, store=store, viewer=viewer))[-tail:]
3988+
events = audit_mod.tail_events(
3989+
list(audit_mod.read_events(store.kb_dir, store=store, viewer=viewer)), tail
3990+
)
39893991
if as_json:
39903992
_emit_json({
39913993
"viewer": {"project": viewer.project, "agent": viewer.agent},

src/vouch/jsonl_server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,9 @@ def _h_audit(p: dict) -> dict:
760760
s = _store()
761761
viewer = viewer_from_params(s, p)
762762
tail = int(p.get("tail", 50))
763-
events = list(audit.read_events(s.kb_dir, store=s, viewer=viewer))[-tail:]
763+
events = audit.tail_events(
764+
list(audit.read_events(s.kb_dir, store=s, viewer=viewer)), tail
765+
)
764766
return {
765767
"viewer": {"project": viewer.project, "agent": viewer.agent},
766768
"events": [e.model_dump(mode="json") for e in events],

src/vouch/retrieval_events.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ def read_events(store: KBStore, *, limit: int | None = None) -> list[dict[str, A
162162
out.append(obj)
163163
except OSError:
164164
return []
165-
if limit is not None and limit >= 0:
166-
return out[-limit:]
167-
return out
165+
if limit is None:
166+
return out
167+
# `out[-0:]` is `out[:]`, so a zero limit would return the whole log.
168+
return out[-limit:] if limit > 0 else []

src/vouch/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1152,7 +1152,9 @@ def kb_audit(
11521152
project=project,
11531153
agent=agent,
11541154
)
1155-
events = list(audit.read_events(store.kb_dir, store=store, viewer=viewer))[-tail:]
1155+
events = audit.tail_events(
1156+
list(audit.read_events(store.kb_dir, store=store, viewer=viewer)), tail
1157+
)
11561158
return {
11571159
"viewer": {"project": viewer.project, "agent": viewer.agent},
11581160
"events": [e.model_dump(mode="json") for e in events],

tests/test_audit.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,31 @@ def store(tmp_path: Path) -> KBStore:
1717
return KBStore.init(tmp_path)
1818

1919

20+
@pytest.mark.parametrize("tail,expected", [(3, 3), (1, 1), (99, 5), (0, 0), (-1, 0)])
21+
def test_tail_events_never_widens_the_window(
22+
store: KBStore, tail: int, expected: int
23+
) -> None:
24+
"""A non-positive tail selects nothing.
25+
26+
`events[-tail:]` reads as "the last N" but `-0 == 0`, so a zero tail
27+
sliced from the start and returned the entire log — the opposite of the
28+
bound asked for. A negative tail dropped that many off the front instead.
29+
"""
30+
for i in range(5):
31+
audit.log_event(store.kb_dir, event=f"x.e{i}", actor="u")
32+
events = list(audit.read_events(store.kb_dir))
33+
34+
assert len(audit.tail_events(events, tail)) == expected
35+
36+
37+
def test_tail_events_keeps_the_newest(store: KBStore) -> None:
38+
for i in range(5):
39+
audit.log_event(store.kb_dir, event=f"x.e{i}", actor="u")
40+
events = list(audit.read_events(store.kb_dir))
41+
42+
assert [e.event for e in audit.tail_events(events, 2)] == ["x.e3", "x.e4"]
43+
44+
2045
def test_audit_log_appends(store: KBStore) -> None:
2146
audit.log_event(store.kb_dir, event="x.test", actor="u", object_ids=["a"])
2247
audit.log_event(store.kb_dir, event="x.test2", actor="u")

tests/test_jsonl_server.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,26 @@ def store(tmp_path: Path) -> KBStore:
1717
return KBStore.init(tmp_path)
1818

1919

20+
def test_jsonl_audit_zero_tail_returns_no_events(store: KBStore, monkeypatch) -> None:
21+
"""`kb.audit` with tail=0 must return nothing, not the whole log.
22+
23+
The window was `events[-tail:]`, and `-0` is `0`, so asking for zero
24+
events sliced from the start and dumped every event the viewer could see.
25+
"""
26+
from vouch import audit
27+
28+
for i in range(4):
29+
audit.log_event(store.kb_dir, event=f"x.e{i}", actor="u")
30+
monkeypatch.chdir(store.root)
31+
32+
resp = handle_request({"id": "r1", "method": "kb.audit", "params": {"tail": 0}})
33+
assert resp["ok"]
34+
assert resp["result"]["events"] == []
35+
36+
bounded = handle_request({"id": "r2", "method": "kb.audit", "params": {"tail": 2}})
37+
assert [e["event"] for e in bounded["result"]["events"]] == ["x.e2", "x.e3"]
38+
39+
2040
def test_jsonl_search_request(store: KBStore, monkeypatch) -> None:
2141
src = store.put_source(b"e")
2242
store.put_claim(Claim(id="c1", text="findable token", evidence=[src.id]))

0 commit comments

Comments
 (0)