Skip to content

Commit a3676f1

Browse files
committed
fix: audit process-safe writes (fcntl), tail-read, lock LRU, oversized pre-check
- AuditLogger.log_event: fcntl.flock + os.fsync for cross-process safety - AuditLogger.read_events: tail-read with byte limit instead of full load - _FILE_LOCKS: LRU-capped at 128 entries, os.path.realpath normalisation - _scanner: oversized pre-check also scans blocklist_commands
1 parent b811d60 commit a3676f1

2 files changed

Lines changed: 54 additions & 18 deletions

File tree

trpc_agent_sdk/tools/safety/_audit.py

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,27 @@
3131

3232
_AUDIT_LOGGER = logging.getLogger("trpc_agent_sdk.tools.safety.audit")
3333

34-
# Per-path locks for thread-safe concurrent writes (threading.Lock is NOT
35-
# process-safe; multi-process deployments should use a dedicated audit daemon
36-
# or file-locking via fcntl/msvcrt).
34+
# Per-path locks for thread-safe AND process-safe concurrent writes via
35+
# fcntl.flock. On platforms without fcntl (Windows), falls back to
36+
# threading.Lock which is only thread-safe.
3737
_FILE_LOCKS: dict[str, threading.Lock] = {}
3838
_FILE_LOCKS_LOCK = threading.Lock()
39+
_MAX_FILE_LOCKS = 128
3940

4041

4142
def _get_file_lock(path: str) -> threading.Lock:
42-
"""Return (and cache) a threading.Lock for the given JSONL path."""
43+
"""Return (and cache) a threading.Lock for the given JSONL path.
44+
45+
The cache is capped at *_MAX_FILE_LOCKS* entries to prevent unbounded
46+
growth when paths contain dynamic segments.
47+
"""
48+
real = os.path.realpath(path)
4349
with _FILE_LOCKS_LOCK:
44-
if path not in _FILE_LOCKS:
45-
_FILE_LOCKS[path] = threading.Lock()
46-
return _FILE_LOCKS[path]
50+
if real not in _FILE_LOCKS:
51+
if len(_FILE_LOCKS) >= _MAX_FILE_LOCKS:
52+
_FILE_LOCKS.pop(next(iter(_FILE_LOCKS)))
53+
_FILE_LOCKS[real] = threading.Lock()
54+
return _FILE_LOCKS[real]
4755

4856

4957
class AuditLogger:
@@ -83,14 +91,21 @@ def log_event(self, report: SafetyScanReport) -> SafetyAuditEvent:
8391
event = self._build_event(report)
8492
line = json.dumps(event.to_dict(), ensure_ascii=False, default=str)
8593

86-
# File output — thread-safe via per-path lock
94+
# File output — thread-safe + process-safe via fcntl.flock
8795
if self._output_path:
8896
lock = _get_file_lock(str(self._output_path))
8997
with lock:
9098
try:
9199
with open(self._output_path, "a", encoding="utf-8") as fh:
100+
# Acquire an OS-level advisory lock for cross-process safety
101+
try:
102+
import fcntl
103+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
104+
except (ImportError, OSError):
105+
pass # fcntl not available (e.g. Windows) — thread-lock only
92106
fh.write(line + "\n")
93107
fh.flush()
108+
os.fsync(fh.fileno())
94109
except OSError as exc:
95110
_AUDIT_LOGGER.error("Failed to write audit event: %s", exc)
96111

@@ -107,23 +122,39 @@ def log_events(self, reports: list[SafetyScanReport]) -> list[SafetyAuditEvent]:
107122
def read_events(self, limit: int = 100) -> list[dict]:
108123
"""Read the most recent audit events from the JSONL file.
109124
125+
Reads from the tail of the file to avoid loading the entire file
126+
into memory. Falls back to a full scan for very small files.
127+
110128
Args:
111129
limit: Maximum number of events to return (most recent first).
112130
113131
Returns:
114-
List of event dicts.
132+
List of event dicts, newest first.
115133
"""
116134
if not self._output_path or not os.path.exists(self._output_path):
117135
return []
136+
path = self._output_path
137+
fsize = os.path.getsize(path)
138+
if fsize == 0:
139+
return []
118140
events: list[dict] = []
119-
with open(self._output_path, "r", encoding="utf-8") as fh:
120-
for line in fh:
121-
line = line.strip()
122-
if line:
123-
try:
124-
events.append(json.loads(line))
125-
except json.JSONDecodeError:
126-
continue
141+
# Estimated bytes per line: average ~200, read 2× to be safe
142+
read_size = min(fsize, max(limit * 400, 8192))
143+
try:
144+
with open(path, "rb") as fh:
145+
if fsize > read_size:
146+
fh.seek(fsize - read_size)
147+
# Skip partial first line (may be truncated)
148+
fh.readline()
149+
for line in fh:
150+
line = line.decode("utf-8", errors="replace").strip()
151+
if line:
152+
try:
153+
events.append(json.loads(line))
154+
except json.JSONDecodeError:
155+
continue
156+
except OSError:
157+
return []
127158
return events[-limit:][::-1]
128159

129160
# ------------------------------------------------------------------

trpc_agent_sdk/tools/safety/_scanner.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def scan(self, scan_input: SafetyScanInput) -> SafetyScanReport:
121121
oversized_reason = (f"Script is {script_bytes} bytes (max {self._policy.max_script_bytes})")
122122

123123
if oversized:
124-
# Fast pre-check: scan blocklist patterns against the full text
124+
# Fast pre-check: scan blocklist patterns AND commands
125125
blocklist_hit = None
126126
for pattern in self._policy.blocklist_patterns:
127127
try:
@@ -130,6 +130,11 @@ def scan(self, scan_input: SafetyScanInput) -> SafetyScanReport:
130130
break
131131
except re.error:
132132
continue
133+
if not blocklist_hit:
134+
for cmd in self._policy.blocklist_commands:
135+
if re.search(re.escape(cmd), script, re.IGNORECASE):
136+
blocklist_hit = cmd
137+
break
133138

134139
oversized_findings = [
135140
SafetyFinding(

0 commit comments

Comments
 (0)