Skip to content

Commit a50e203

Browse files
author
goweft
committed
audit: add query filters + file locking for chain integrity\n\naudit.py:\n - recent() gains agent, tool, since, until filters. All composable.\n - _write_entry() now acquires an exclusive file lock (fcntl.LOCK_EX)\n and re-reads the last chain hash under lock before writing. Fixes\n chain breaks caused by concurrent writers (e.g. dashboard + test\n suite both appending). Graceful fallback on Windows (no fcntl).\n\ncli.py:\n - 'heddle audit show' gains --agent, --tool, --since, --until options\n in addition to existing -n/--count and --event.\n - Example: heddle audit show --agent prometheus-bridge --since 2026-04\n heddle audit show -n 10 --event trust_violation --tool dangerous\n\ntests: 137 -> 142 (+5)\n - test_audit_filter_by_agent\n - test_audit_filter_by_tool\n - test_audit_filter_by_time_range\n - test_audit_filter_combined\n - test_audit_filter_no_matches\n\nNote: the existing audit log at ~/.heddle/audit/audit.jsonl has a chain\nbreak at line 2046 from before this fix (two processes writing without\nlocking on 2026-03-29). The break is historical — new entries written\nafter this commit will maintain chain integrity even under concurrent\nwrites. heddle audit verify correctly reports the break.\n\nv0.2 Pillar 2 (audit query CLI).
1 parent 3a9ab1e commit a50e203

3 files changed

Lines changed: 133 additions & 13 deletions

File tree

src/heddle/cli.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,17 @@ def audit():
218218
@audit.command("show")
219219
@click.option("-n", "--count", default=20, help="Number of entries")
220220
@click.option("--event", default=None, help="Filter by event type")
221-
def audit_show(count: int, event: str | None):
221+
@click.option("--agent", default=None, help="Filter by agent name")
222+
@click.option("--tool", default=None, help="Filter by tool name")
223+
@click.option("--since", default=None, help="Only entries after this ISO timestamp")
224+
@click.option("--until", "until_", default=None, help="Only entries before this ISO timestamp")
225+
def audit_show(count: int, event: str | None, agent: str | None,
226+
tool: str | None, since: str | None, until_: str | None):
222227
"""Show recent audit log entries."""
223228
from heddle.security.audit import get_audit_logger
224229
logger = get_audit_logger()
225-
entries = logger.recent(count, event_type=event)
230+
entries = logger.recent(count, event_type=event, agent=agent,
231+
tool=tool, since=since, until=until_)
226232
if not entries:
227233
console.print("[dim]No audit entries.[/]")
228234
return

src/heddle/security/audit.py

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
from pathlib import Path
1919
from typing import Any
2020

21+
try:
22+
import fcntl
23+
_HAS_FCNTL = True
24+
except ImportError:
25+
_HAS_FCNTL = False # Windows
26+
2127
logger = logging.getLogger(__name__)
2228

2329
DEFAULT_LOG_DIR = Path.home() / ".heddle" / "audit"
@@ -58,15 +64,30 @@ def _compute_last_hash(self) -> str:
5864
return "GENESIS"
5965

6066
def _write_entry(self, entry: dict[str, Any]) -> None:
61-
"""Write a single audit entry, updating the chain hash."""
62-
entry["chain_hash"] = self._prev_hash
63-
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
64-
65-
line = json.dumps(entry, default=str, separators=(",", ":"))
66-
self._prev_hash = hashlib.sha256(line.encode()).hexdigest()
67+
"""Write a single audit entry, updating the chain hash.
6768
69+
Uses file locking to prevent chain breaks when multiple
70+
processes (e.g. dashboard + test suite) write concurrently.
71+
"""
6872
with open(self._log_file, "a") as f:
69-
f.write(line + "\n")
73+
if _HAS_FCNTL:
74+
fcntl.flock(f, fcntl.LOCK_EX)
75+
try:
76+
# Re-read last hash under lock in case another process
77+
# appended since we last computed it.
78+
self._prev_hash = self._compute_last_hash()
79+
80+
entry["chain_hash"] = self._prev_hash
81+
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
82+
83+
line = json.dumps(entry, default=str, separators=(",", ":"))
84+
self._prev_hash = hashlib.sha256(line.encode()).hexdigest()
85+
86+
f.write(line + "\n")
87+
f.flush()
88+
finally:
89+
if _HAS_FCNTL:
90+
fcntl.flock(f, fcntl.LOCK_UN)
7091

7192
# ── Public logging methods ───────────────────────────────────────
7293

@@ -191,8 +212,25 @@ def verify_chain(self) -> tuple[bool, int, str]:
191212

192213
return True, count, f"Chain valid: {count} entries"
193214

194-
def recent(self, n: int = 20, event_type: str | None = None) -> list[dict]:
195-
"""Read the most recent N entries, optionally filtered by event type."""
215+
def recent(
216+
self,
217+
n: int = 20,
218+
event_type: str | None = None,
219+
agent: str | None = None,
220+
tool: str | None = None,
221+
since: str | None = None,
222+
until: str | None = None,
223+
) -> list[dict]:
224+
"""Read the most recent N entries with optional filters.
225+
226+
Args:
227+
n: Maximum entries to return.
228+
event_type: Filter by event type (tool_call, http_bridge, etc.).
229+
agent: Filter by agent (config) name.
230+
tool: Filter by tool name.
231+
since: ISO timestamp — only entries at or after this time.
232+
until: ISO timestamp — only entries at or before this time.
233+
"""
196234
if not self._log_file.exists():
197235
return []
198236

@@ -204,11 +242,23 @@ def recent(self, n: int = 20, event_type: str | None = None) -> list[dict]:
204242
continue
205243
try:
206244
entry = json.loads(line)
207-
if event_type is None or entry.get("event") == event_type:
208-
entries.append(entry)
209245
except json.JSONDecodeError:
210246
continue
211247

248+
if event_type is not None and entry.get("event") != event_type:
249+
continue
250+
if agent is not None and entry.get("agent") != agent:
251+
continue
252+
if tool is not None and entry.get("tool") != tool:
253+
continue
254+
ts = entry.get("timestamp", "")
255+
if since is not None and ts < since:
256+
continue
257+
if until is not None and ts > until:
258+
continue
259+
260+
entries.append(entry)
261+
212262
return entries[-n:]
213263

214264

tests/test_security.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,70 @@ def test_audit_filter_by_event(audit):
9494
assert len(http_entries) == 1
9595

9696

97+
98+
99+
def test_audit_filter_by_agent(audit):
100+
audit.log_tool_call("agent-a", "t1", {}, "success")
101+
audit.log_tool_call("agent-b", "t2", {}, "success")
102+
audit.log_tool_call("agent-a", "t3", {}, "success")
103+
104+
a_entries = audit.recent(10, agent="agent-a")
105+
assert len(a_entries) == 2
106+
assert all(e["agent"] == "agent-a" for e in a_entries)
107+
108+
b_entries = audit.recent(10, agent="agent-b")
109+
assert len(b_entries) == 1
110+
111+
112+
def test_audit_filter_by_tool(audit):
113+
audit.log_tool_call("a", "query_prometheus", {}, "success")
114+
audit.log_tool_call("a", "get_alerts", {}, "success")
115+
audit.log_tool_call("a", "query_prometheus", {}, "success")
116+
117+
entries = audit.recent(10, tool="query_prometheus")
118+
assert len(entries) == 2
119+
assert all(e["tool"] == "query_prometheus" for e in entries)
120+
121+
122+
def test_audit_filter_by_time_range(audit):
123+
audit.log_tool_call("a", "t1", {}, "success")
124+
audit.log_tool_call("a", "t2", {}, "success")
125+
audit.log_tool_call("a", "t3", {}, "success")
126+
127+
entries = audit.recent(10)
128+
assert len(entries) == 3
129+
130+
# Use the timestamp of the second entry as a boundary
131+
mid_ts = entries[1]["timestamp"]
132+
since_entries = audit.recent(10, since=mid_ts)
133+
assert len(since_entries) >= 2 # second and third entry
134+
135+
until_entries = audit.recent(10, until=mid_ts)
136+
assert len(until_entries) >= 1 # first entry at minimum
137+
138+
139+
def test_audit_filter_combined(audit):
140+
audit.log_tool_call("agent-a", "t1", {}, "success")
141+
audit.log_http_bridge("agent-a", "t1", "GET", "http://x", status_code=200)
142+
audit.log_tool_call("agent-b", "t2", {}, "success")
143+
audit.log_tool_call("agent-a", "t2", {}, "error", error="timeout")
144+
145+
# agent + event type
146+
entries = audit.recent(10, event_type="tool_call", agent="agent-a")
147+
assert len(entries) == 2
148+
assert all(e["agent"] == "agent-a" and e["event"] == "tool_call" for e in entries)
149+
150+
# agent + tool
151+
entries = audit.recent(10, agent="agent-a", tool="t1")
152+
assert len(entries) == 2 # tool_call + http_bridge both have tool=t1
153+
154+
155+
def test_audit_filter_no_matches(audit):
156+
audit.log_tool_call("a", "t1", {}, "success")
157+
entries = audit.recent(10, agent="nonexistent")
158+
assert entries == []
159+
160+
97161
# ── Trust Enforcer ───────────────────────────────────────────────────
98162

99163
@pytest.fixture(autouse=True)

0 commit comments

Comments
 (0)