agentsnitch 0.1.0__py3-none-any.whl
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.
- agentsnitch/__init__.py +2 -0
- agentsnitch/__main__.py +5 -0
- agentsnitch/agentlogs.py +353 -0
- agentsnitch/audit.py +158 -0
- agentsnitch/cli.py +151 -0
- agentsnitch/rules.py +172 -0
- agentsnitch-0.1.0.dist-info/METADATA +88 -0
- agentsnitch-0.1.0.dist-info/RECORD +11 -0
- agentsnitch-0.1.0.dist-info/WHEEL +4 -0
- agentsnitch-0.1.0.dist-info/entry_points.txt +2 -0
- agentsnitch-0.1.0.dist-info/licenses/LICENSE +21 -0
agentsnitch/__init__.py
ADDED
agentsnitch/__main__.py
ADDED
agentsnitch/agentlogs.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Read the session logs that AI coding agents keep on your machine, into one common shape.
|
|
2
|
+
|
|
3
|
+
Supported (all read-only, nothing leaves your machine):
|
|
4
|
+
- Claude Code: ~/.claude/projects/<project>/<session>.jsonl (and subagent logs under it)
|
|
5
|
+
- Codex CLI: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
6
|
+
- Gemini CLI: ~/.gemini/tmp/<project>/chats/session-*.json(l), with ~/.gemini/projects.json for folders
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterator
|
|
17
|
+
|
|
18
|
+
AGENTS = {"claude": "Claude Code", "codex": "Codex", "gemini": "Gemini CLI"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Event:
|
|
23
|
+
agent: str # "claude" | "codex" | "gemini"
|
|
24
|
+
session: str
|
|
25
|
+
ts: float | None
|
|
26
|
+
cwd: str | None
|
|
27
|
+
kind: str # "prompt" | "command" | "read" | "write" | "edit" | "fetch" | "tool"
|
|
28
|
+
tool: str = ""
|
|
29
|
+
command: str = "" # shell command line
|
|
30
|
+
path: str = "" # file read/written
|
|
31
|
+
added: list[str] = field(default_factory=list) # lines the agent wrote
|
|
32
|
+
text: str = "" # prompt text, URL, etc.
|
|
33
|
+
model: str = ""
|
|
34
|
+
tokens: int = 0 # tokens used by this turn, where the log says
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_ts(s) -> float | None:
|
|
38
|
+
if not s:
|
|
39
|
+
return None
|
|
40
|
+
if isinstance(s, (int, float)):
|
|
41
|
+
return float(s) / (1000 if s > 1e11 else 1)
|
|
42
|
+
try:
|
|
43
|
+
return datetime.fromisoformat(str(s).replace("Z", "+00:00")).timestamp()
|
|
44
|
+
except ValueError:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _lines(text: str) -> list[str]:
|
|
49
|
+
return text.splitlines() if isinstance(text, str) else []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _jsonl(path: Path) -> Iterator[dict]:
|
|
53
|
+
try:
|
|
54
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
55
|
+
for line in fh:
|
|
56
|
+
line = line.strip()
|
|
57
|
+
if not line:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
d = json.loads(line)
|
|
61
|
+
except ValueError:
|
|
62
|
+
continue
|
|
63
|
+
if isinstance(d, dict):
|
|
64
|
+
yield d
|
|
65
|
+
except OSError:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Claude Code
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
def claude_root() -> Path:
|
|
73
|
+
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude") / "projects"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _prompt_text(content) -> str:
|
|
77
|
+
if isinstance(content, str):
|
|
78
|
+
return content
|
|
79
|
+
if isinstance(content, list):
|
|
80
|
+
return "\n".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text")
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def claude_events(path: Path) -> Iterator[Event]:
|
|
85
|
+
for d in _jsonl(path):
|
|
86
|
+
typ = d.get("type")
|
|
87
|
+
if typ not in ("user", "assistant"):
|
|
88
|
+
continue
|
|
89
|
+
ts, cwd, sid = parse_ts(d.get("timestamp")), d.get("cwd"), d.get("sessionId") or path.stem
|
|
90
|
+
msg = d.get("message") or {}
|
|
91
|
+
content = msg.get("content")
|
|
92
|
+
if typ == "user":
|
|
93
|
+
if d.get("isMeta") or d.get("isCompactSummary") or d.get("toolUseResult") is not None:
|
|
94
|
+
continue
|
|
95
|
+
text = _prompt_text(content)
|
|
96
|
+
if text and not text.startswith(("<command-", "<local-command", "Caveat:", "[Request interrupted")) \
|
|
97
|
+
and not (isinstance(content, list) and any(b.get("type") == "tool_result" for b in content
|
|
98
|
+
if isinstance(b, dict))):
|
|
99
|
+
yield Event("claude", sid, ts, cwd, "prompt", text=text)
|
|
100
|
+
continue
|
|
101
|
+
usage = msg.get("usage") or {}
|
|
102
|
+
tokens = sum(int(usage.get(k) or 0) for k in ("input_tokens", "output_tokens", "cache_creation_input_tokens"))
|
|
103
|
+
model = msg.get("model") or ""
|
|
104
|
+
first = True
|
|
105
|
+
for b in content if isinstance(content, list) else []:
|
|
106
|
+
if not isinstance(b, dict) or b.get("type") != "tool_use":
|
|
107
|
+
continue
|
|
108
|
+
name, inp = b.get("name") or "", b.get("input") or {}
|
|
109
|
+
ev = Event("claude", sid, ts, cwd, "tool", tool=name, model=model, tokens=tokens if first else 0)
|
|
110
|
+
first = False
|
|
111
|
+
if name == "Bash":
|
|
112
|
+
ev.kind, ev.command = "command", str(inp.get("command") or "")
|
|
113
|
+
elif name in ("Read", "NotebookRead"):
|
|
114
|
+
ev.kind, ev.path = "read", str(inp.get("file_path") or inp.get("notebook_path") or "")
|
|
115
|
+
elif name == "Write":
|
|
116
|
+
ev.kind, ev.path, ev.added = "write", str(inp.get("file_path") or ""), _lines(inp.get("content", ""))
|
|
117
|
+
elif name == "Edit":
|
|
118
|
+
ev.kind, ev.path, ev.added = "edit", str(inp.get("file_path") or ""), _lines(inp.get("new_string", ""))
|
|
119
|
+
elif name == "MultiEdit":
|
|
120
|
+
ev.kind, ev.path = "edit", str(inp.get("file_path") or "")
|
|
121
|
+
for e in inp.get("edits") or []:
|
|
122
|
+
ev.added += _lines((e or {}).get("new_string", ""))
|
|
123
|
+
elif name == "NotebookEdit":
|
|
124
|
+
ev.kind, ev.path, ev.added = "edit", str(inp.get("notebook_path") or ""), _lines(inp.get("new_source", ""))
|
|
125
|
+
elif name in ("WebFetch", "WebSearch"):
|
|
126
|
+
ev.kind, ev.text = "fetch", str(inp.get("url") or inp.get("query") or "")
|
|
127
|
+
elif name in ("Grep", "Glob", "LS"):
|
|
128
|
+
ev.kind, ev.path = "read", str(inp.get("path") or "")
|
|
129
|
+
yield ev
|
|
130
|
+
if first and tokens: # a turn with no tool calls still used tokens
|
|
131
|
+
yield Event("claude", sid, ts, cwd, "tool", tool="", model=model, tokens=tokens)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Codex CLI
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
def codex_root() -> Path:
|
|
138
|
+
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "sessions"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
PATCH_FILE = re.compile(r"^\*\*\* (Add|Update|Delete) File: (.+)$")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_apply_patch(patch: str) -> list[tuple[str, list[str]]]:
|
|
145
|
+
"""[(path, added lines)] from Codex's *** Begin Patch format."""
|
|
146
|
+
out: list[tuple[str, list[str]]] = []
|
|
147
|
+
for line in patch.splitlines():
|
|
148
|
+
m = PATCH_FILE.match(line)
|
|
149
|
+
if m:
|
|
150
|
+
out.append((m.group(2).strip(), []))
|
|
151
|
+
elif out and line.startswith("+") and not line.startswith("+++"):
|
|
152
|
+
out[-1][1].append(line[1:])
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _codex_command(name: str, args) -> str:
|
|
157
|
+
if isinstance(args, str):
|
|
158
|
+
try:
|
|
159
|
+
args = json.loads(args)
|
|
160
|
+
except ValueError:
|
|
161
|
+
return args if name in ("shell_command",) else ""
|
|
162
|
+
if not isinstance(args, dict):
|
|
163
|
+
return ""
|
|
164
|
+
cmd = args.get("cmd", args.get("command"))
|
|
165
|
+
if isinstance(cmd, list):
|
|
166
|
+
# ["bash", "-lc", "real command"] -> "real command"
|
|
167
|
+
if len(cmd) >= 3 and cmd[1] in ("-lc", "-c") and os.path.basename(str(cmd[0])) in ("bash", "sh", "zsh"):
|
|
168
|
+
return str(cmd[2])
|
|
169
|
+
return " ".join(str(c) for c in cmd)
|
|
170
|
+
return str(cmd or "")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def codex_events(path: Path) -> Iterator[Event]:
|
|
174
|
+
sid, cwd, model = path.stem.replace("rollout-", ""), None, ""
|
|
175
|
+
last_total = 0
|
|
176
|
+
for d in _jsonl(path):
|
|
177
|
+
typ, p = d.get("type"), d.get("payload") or {}
|
|
178
|
+
ts = parse_ts(d.get("timestamp"))
|
|
179
|
+
if typ == "session_meta":
|
|
180
|
+
sid = str(p.get("id") or p.get("session_id") or sid)
|
|
181
|
+
cwd = p.get("cwd") or cwd
|
|
182
|
+
continue
|
|
183
|
+
if typ == "turn_context":
|
|
184
|
+
cwd = p.get("cwd") or cwd
|
|
185
|
+
model = p.get("model") or model
|
|
186
|
+
continue
|
|
187
|
+
if typ == "event_msg":
|
|
188
|
+
if p.get("type") == "user_message" and p.get("message"):
|
|
189
|
+
yield Event("codex", sid, ts, cwd, "prompt", text=str(p["message"]))
|
|
190
|
+
elif p.get("type") == "token_count" and isinstance(p.get("info"), dict):
|
|
191
|
+
total = (p["info"].get("total_token_usage") or {}).get("total_tokens") or 0
|
|
192
|
+
if total > last_total:
|
|
193
|
+
yield Event("codex", sid, ts, cwd, "tool", model=model, tokens=int(total - last_total))
|
|
194
|
+
last_total = total
|
|
195
|
+
continue
|
|
196
|
+
if typ != "response_item":
|
|
197
|
+
continue
|
|
198
|
+
ptype, name = p.get("type"), p.get("name") or ""
|
|
199
|
+
if ptype == "local_shell_call":
|
|
200
|
+
cmd = _codex_command("shell", (p.get("action") or {}))
|
|
201
|
+
yield Event("codex", sid, ts, cwd, "command", tool="local_shell", command=cmd, model=model)
|
|
202
|
+
elif ptype in ("function_call", "custom_tool_call"):
|
|
203
|
+
raw = p.get("arguments") if ptype == "function_call" else p.get("input")
|
|
204
|
+
if name == "apply_patch":
|
|
205
|
+
patch = raw
|
|
206
|
+
if ptype == "function_call":
|
|
207
|
+
try:
|
|
208
|
+
patch = json.loads(raw).get("input", "")
|
|
209
|
+
except (ValueError, AttributeError):
|
|
210
|
+
patch = ""
|
|
211
|
+
for fpath, added in parse_apply_patch(patch or ""):
|
|
212
|
+
yield Event("codex", sid, ts, cwd, "edit", tool="apply_patch", path=fpath, added=added, model=model)
|
|
213
|
+
elif name in ("shell", "exec_command", "shell_command", "container.exec"):
|
|
214
|
+
cmd = _codex_command(name, raw)
|
|
215
|
+
if "apply_patch" in cmd and "*** Begin Patch" in cmd: # older Codex sent patches through the shell
|
|
216
|
+
for fpath, added in parse_apply_patch(cmd):
|
|
217
|
+
yield Event("codex", sid, ts, cwd, "edit", tool="apply_patch", path=fpath, added=added,
|
|
218
|
+
model=model)
|
|
219
|
+
else:
|
|
220
|
+
yield Event("codex", sid, ts, cwd, "command", tool=name, command=cmd, model=model)
|
|
221
|
+
elif name in ("web_search", "web_fetch"):
|
|
222
|
+
yield Event("codex", sid, ts, cwd, "fetch", tool=name, text=str(raw)[:300], model=model)
|
|
223
|
+
else:
|
|
224
|
+
yield Event("codex", sid, ts, cwd, "tool", tool=name, model=model)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Gemini CLI
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
def gemini_home() -> Path:
|
|
231
|
+
return Path.home() / ".gemini"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def gemini_projects() -> dict[str, str]:
|
|
235
|
+
"""shortId -> project folder, from ~/.gemini/projects.json."""
|
|
236
|
+
for base in (gemini_home(), Path.home() / ".cache" / ".gemini"):
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads((base / "projects.json").read_text())
|
|
239
|
+
return {v: k for k, v in (data.get("projects") or {}).items()}
|
|
240
|
+
except (OSError, ValueError, AttributeError):
|
|
241
|
+
continue
|
|
242
|
+
return {}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _gemini_records(path: Path) -> tuple[dict, list[dict]]:
|
|
246
|
+
"""(metadata, messages) for both the old single-JSON and the newer JSONL session files."""
|
|
247
|
+
if path.suffix == ".json":
|
|
248
|
+
try:
|
|
249
|
+
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
250
|
+
except (OSError, ValueError):
|
|
251
|
+
return {}, []
|
|
252
|
+
return data, list(data.get("messages") or [])
|
|
253
|
+
meta: dict = {}
|
|
254
|
+
msgs: dict[str, dict] = {}
|
|
255
|
+
order: list[str] = []
|
|
256
|
+
for d in _jsonl(path):
|
|
257
|
+
if "sessionId" in d and "projectHash" in d and "type" not in d:
|
|
258
|
+
meta.update(d)
|
|
259
|
+
elif "$set" in d:
|
|
260
|
+
meta.update(d["$set"] if isinstance(d["$set"], dict) else {})
|
|
261
|
+
elif isinstance(d.get("id"), str):
|
|
262
|
+
if d["id"] in msgs: # a later patch of the same message (e.g. tool results filled in)
|
|
263
|
+
cur = msgs[d["id"]]
|
|
264
|
+
for k, v in d.items():
|
|
265
|
+
if k == "toolCalls" and isinstance(v, list):
|
|
266
|
+
known = {t.get("id"): t for t in cur.get("toolCalls") or []}
|
|
267
|
+
for t in v:
|
|
268
|
+
known.setdefault(t.get("id"), {}).update(t)
|
|
269
|
+
cur["toolCalls"] = list(known.values())
|
|
270
|
+
elif k != "$patch":
|
|
271
|
+
cur[k] = v
|
|
272
|
+
else:
|
|
273
|
+
msgs[d["id"]] = dict(d)
|
|
274
|
+
order.append(d["id"])
|
|
275
|
+
return meta, [msgs[i] for i in order]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def gemini_events(path: Path, projects: dict[str, str] | None = None) -> Iterator[Event]:
|
|
279
|
+
meta, messages = _gemini_records(path)
|
|
280
|
+
sid = str(meta.get("sessionId") or path.stem)
|
|
281
|
+
short = path.parent.parent.name if path.parent.name == "chats" else ""
|
|
282
|
+
cwd = (projects or {}).get(short)
|
|
283
|
+
for m in messages:
|
|
284
|
+
ts = parse_ts(m.get("timestamp"))
|
|
285
|
+
if m.get("type") == "user":
|
|
286
|
+
text = m.get("content")
|
|
287
|
+
if isinstance(text, list):
|
|
288
|
+
text = "\n".join(p.get("text", "") for p in text if isinstance(p, dict))
|
|
289
|
+
if text:
|
|
290
|
+
yield Event("gemini", sid, ts, cwd, "prompt", text=str(text))
|
|
291
|
+
continue
|
|
292
|
+
if m.get("type") != "gemini":
|
|
293
|
+
continue
|
|
294
|
+
tok = m.get("tokens") or {}
|
|
295
|
+
tokens = int(tok.get("total") or 0) if isinstance(tok, dict) else 0
|
|
296
|
+
model = m.get("model") or ""
|
|
297
|
+
calls = m.get("toolCalls") or []
|
|
298
|
+
for i, tc in enumerate(calls):
|
|
299
|
+
name, args = tc.get("name") or "", tc.get("args") or {}
|
|
300
|
+
ev = Event("gemini", sid, parse_ts(tc.get("timestamp")) or ts, cwd, "tool", tool=name, model=model,
|
|
301
|
+
tokens=tokens if i == 0 else 0)
|
|
302
|
+
if name in ("run_shell_command", "ShellTool"):
|
|
303
|
+
ev.kind, ev.command = "command", str(args.get("command") or "")
|
|
304
|
+
if args.get("dir_path") and not cwd:
|
|
305
|
+
ev.cwd = str(args["dir_path"])
|
|
306
|
+
elif name == "write_file":
|
|
307
|
+
ev.kind, ev.path, ev.added = "write", str(args.get("file_path") or ""), _lines(args.get("content", ""))
|
|
308
|
+
elif name in ("replace", "edit"):
|
|
309
|
+
ev.kind, ev.path, ev.added = "edit", str(args.get("file_path") or ""), _lines(args.get("new_string", ""))
|
|
310
|
+
elif name in ("read_file", "read_many_files", "glob", "search_file_content", "grep_search", "list_directory"):
|
|
311
|
+
ev.kind = "read"
|
|
312
|
+
ev.path = str(args.get("file_path") or args.get("absolute_path") or args.get("path") or
|
|
313
|
+
args.get("dir_path") or "")
|
|
314
|
+
elif name in ("web_fetch", "google_web_search"):
|
|
315
|
+
ev.kind, ev.text = "fetch", str(args.get("url") or args.get("prompt") or args.get("query") or "")[:300]
|
|
316
|
+
yield ev
|
|
317
|
+
if not calls and tokens:
|
|
318
|
+
yield Event("gemini", sid, ts, cwd, "tool", model=model, tokens=tokens)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Everything
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
def session_files(roots: dict[str, Path] | None = None) -> list[tuple[str, Path]]:
|
|
325
|
+
roots = roots or {"claude": claude_root(), "codex": codex_root(), "gemini": gemini_home() / "tmp"}
|
|
326
|
+
out: list[tuple[str, Path]] = []
|
|
327
|
+
if roots.get("claude") and roots["claude"].is_dir():
|
|
328
|
+
out += [("claude", p) for p in sorted(roots["claude"].rglob("*.jsonl"))]
|
|
329
|
+
if roots.get("codex") and roots["codex"].is_dir():
|
|
330
|
+
out += [("codex", p) for p in sorted(roots["codex"].rglob("rollout-*.jsonl"))]
|
|
331
|
+
if roots.get("gemini") and roots["gemini"].is_dir():
|
|
332
|
+
out += [("gemini", p) for p in sorted(roots["gemini"].glob("*/chats/session-*.json*"))]
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def all_events(roots: dict[str, Path] | None = None, since: float | None = None) -> Iterator[Event]:
|
|
337
|
+
projects = gemini_projects()
|
|
338
|
+
for agent, path in session_files(roots):
|
|
339
|
+
try:
|
|
340
|
+
if since and path.stat().st_mtime < since:
|
|
341
|
+
continue # untouched since before the window: nothing in it can be newer
|
|
342
|
+
except OSError:
|
|
343
|
+
continue
|
|
344
|
+
if agent == "claude":
|
|
345
|
+
it = claude_events(path)
|
|
346
|
+
elif agent == "codex":
|
|
347
|
+
it = codex_events(path)
|
|
348
|
+
else:
|
|
349
|
+
it = gemini_events(path, projects)
|
|
350
|
+
for ev in it:
|
|
351
|
+
if since and ev.ts and ev.ts < since:
|
|
352
|
+
continue
|
|
353
|
+
yield ev
|
agentsnitch/audit.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Go through everything your agents did and pull out what deserves a second look."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from .agentlogs import Event
|
|
9
|
+
from .rules import HIGH, LOW, MEDIUM, classify_command, classify_path, outside
|
|
10
|
+
|
|
11
|
+
ORDER = {HIGH: 0, MEDIUM: 1, LOW: 2}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Finding:
|
|
16
|
+
key: str
|
|
17
|
+
title: str
|
|
18
|
+
severity: str
|
|
19
|
+
agent: str
|
|
20
|
+
project: str
|
|
21
|
+
ts: float | None
|
|
22
|
+
detail: str # the matching part of the command, or the file path
|
|
23
|
+
session: str
|
|
24
|
+
command: str = "" # the whole command
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Audit:
|
|
29
|
+
since: float | None
|
|
30
|
+
commands: int = 0
|
|
31
|
+
reads: int = 0
|
|
32
|
+
writes: int = 0
|
|
33
|
+
fetches: int = 0
|
|
34
|
+
prompts: int = 0
|
|
35
|
+
sessions: set = field(default_factory=set)
|
|
36
|
+
projects: Counter = field(default_factory=Counter)
|
|
37
|
+
agents: Counter = field(default_factory=Counter) # agent -> actions
|
|
38
|
+
top_commands: Counter = field(default_factory=Counter) # first word of each command
|
|
39
|
+
findings: list[Finding] = field(default_factory=list)
|
|
40
|
+
outside_writes: list[Finding] = field(default_factory=list)
|
|
41
|
+
outside_reads: list[Finding] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def actions(self) -> int:
|
|
45
|
+
return self.commands + self.reads + self.writes + self.fetches
|
|
46
|
+
|
|
47
|
+
def by_rule(self) -> list[tuple[str, str, int, Finding]]:
|
|
48
|
+
"""(title, severity, count, most recent example), most serious first."""
|
|
49
|
+
groups: dict[str, list[Finding]] = defaultdict(list)
|
|
50
|
+
for f in self.findings:
|
|
51
|
+
groups[f.key].append(f)
|
|
52
|
+
out = []
|
|
53
|
+
for key, fs in groups.items():
|
|
54
|
+
fs.sort(key=lambda f: f.ts or 0)
|
|
55
|
+
out.append((fs[-1].title, fs[-1].severity, len(fs), fs[-1]))
|
|
56
|
+
if self.outside_writes:
|
|
57
|
+
fs = sorted(self.outside_writes, key=lambda f: f.ts or 0)
|
|
58
|
+
out.append(("Wrote files outside the project", MEDIUM, len(fs), fs[-1]))
|
|
59
|
+
if self.outside_reads:
|
|
60
|
+
fs = sorted(self.outside_reads, key=lambda f: f.ts or 0)
|
|
61
|
+
out.append(("Read files outside the project", LOW, len(fs), fs[-1]))
|
|
62
|
+
return sorted(out, key=lambda x: (ORDER[x[1]], -x[2]))
|
|
63
|
+
|
|
64
|
+
def count(self, key: str) -> int:
|
|
65
|
+
return sum(1 for f in self.findings if f.key == key)
|
|
66
|
+
|
|
67
|
+
def highlights(self) -> list[str]:
|
|
68
|
+
"""The few facts worth putting in a screenshot."""
|
|
69
|
+
h = []
|
|
70
|
+
for key, one, many in [("force-push", "force-push", "force-pushes"),
|
|
71
|
+
("pipe-to-shell", "curl | sh", "curl | sh runs"),
|
|
72
|
+
("rm-root", "rm -rf on your home folder", "rm -rf runs on your home folder"),
|
|
73
|
+
("ssh-keys", "SSH key access", "SSH key accesses"),
|
|
74
|
+
("cloud-creds", "cloud credential read", "cloud credential reads"),
|
|
75
|
+
("keychain", "password-store read", "password-store reads"),
|
|
76
|
+
("dotenv", ".env read", ".env reads"),
|
|
77
|
+
("publish", "package publish", "package publishes"),
|
|
78
|
+
("drop-db", "dropped database", "dropped databases"),
|
|
79
|
+
("sudo", "sudo", "sudos")]:
|
|
80
|
+
n = self.count(key)
|
|
81
|
+
if n:
|
|
82
|
+
h.append(f"{n} {one if n == 1 else many}")
|
|
83
|
+
if self.outside_writes:
|
|
84
|
+
n = len({f.detail for f in self.outside_writes})
|
|
85
|
+
h.append(f"{n} file{'s' * (n != 1)} written outside the project")
|
|
86
|
+
return h
|
|
87
|
+
|
|
88
|
+
def period(self) -> str:
|
|
89
|
+
import time
|
|
90
|
+
if not self.since:
|
|
91
|
+
return "so far"
|
|
92
|
+
days = round((time.time() - self.since) / 86400)
|
|
93
|
+
return "today" if days <= 1 else f"in the last {days} days"
|
|
94
|
+
|
|
95
|
+
def share(self) -> str:
|
|
96
|
+
bits = self.highlights()[:3]
|
|
97
|
+
s = f"My AI coding agents ran {self.commands:,} commands {self.period()}"
|
|
98
|
+
s += (": " + ", ".join(bits)) if bits else ", and nothing scary"
|
|
99
|
+
return s + ". What did yours do? uvx agentsnitch"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
SKIP = {"cd", "export", "set", "source", ".", "for", "do", "done", "if", "then", "fi", "else", "while", "echo",
|
|
103
|
+
"true", "false", "sleep", "time", "env", "exec", "(", "{", "}", "then", "local"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def programs(cmd: str) -> list[str]:
|
|
107
|
+
"""The programs a command line runs: `cd x && npm test | tee log` -> ["npm", "tee"]."""
|
|
108
|
+
import re
|
|
109
|
+
from .rules import shell_only
|
|
110
|
+
out = []
|
|
111
|
+
for seg in re.split(r"&&|\|\||[;|\n]", shell_only(cmd)):
|
|
112
|
+
words = [w for w in seg.strip().split() if not re.match(r"^\w+=", w)]
|
|
113
|
+
if words and words[0] not in SKIP and re.match(r"^[\w./-]+$", words[0]):
|
|
114
|
+
out.append(os.path.basename(words[0]))
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def project_of(ev: Event) -> str:
|
|
119
|
+
return ev.cwd or "?"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def run(events, since: float | None = None) -> Audit:
|
|
123
|
+
a = Audit(since)
|
|
124
|
+
roots: dict[tuple[str, str], str] = {} # each session's project = the folder the agent was started in
|
|
125
|
+
for ev in events:
|
|
126
|
+
a.agents[ev.agent] += ev.kind != "prompt"
|
|
127
|
+
a.sessions.add((ev.agent, ev.session))
|
|
128
|
+
if ev.cwd:
|
|
129
|
+
roots.setdefault((ev.agent, ev.session), ev.cwd)
|
|
130
|
+
proj = roots.get((ev.agent, ev.session)) or project_of(ev)
|
|
131
|
+
if ev.kind == "prompt":
|
|
132
|
+
a.prompts += 1
|
|
133
|
+
continue
|
|
134
|
+
if ev.kind not in ("command", "read", "write", "edit", "fetch"):
|
|
135
|
+
continue
|
|
136
|
+
a.projects[proj] += 1
|
|
137
|
+
if ev.kind == "command":
|
|
138
|
+
a.commands += 1
|
|
139
|
+
for prog in programs(ev.command):
|
|
140
|
+
a.top_commands[prog] += 1
|
|
141
|
+
for r, snippet in classify_command(ev.command):
|
|
142
|
+
a.findings.append(Finding(r.key, r.title, r.severity, ev.agent, proj, ev.ts, snippet, ev.session,
|
|
143
|
+
ev.command))
|
|
144
|
+
elif ev.kind == "fetch":
|
|
145
|
+
a.fetches += 1
|
|
146
|
+
else:
|
|
147
|
+
if ev.kind == "read":
|
|
148
|
+
a.reads += 1
|
|
149
|
+
else:
|
|
150
|
+
a.writes += 1
|
|
151
|
+
for key, title, sev in classify_path(ev.path, ev.kind):
|
|
152
|
+
a.findings.append(Finding(key, title, sev, ev.agent, proj, ev.ts, ev.path, ev.session))
|
|
153
|
+
path = ev.path if os.path.isabs(ev.path) or not ev.cwd else os.path.join(ev.cwd, ev.path)
|
|
154
|
+
if outside(path, proj if proj != "?" else None):
|
|
155
|
+
f = Finding("outside", "", MEDIUM if ev.kind != "read" else LOW, ev.agent, proj, ev.ts, ev.path,
|
|
156
|
+
ev.session)
|
|
157
|
+
(a.outside_reads if ev.kind == "read" else a.outside_writes).append(f)
|
|
158
|
+
return a
|
agentsnitch/cli.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""agentsnitch: what did your AI coding agents actually do on your machine?"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.console import Console, Group
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from . import __version__, agentlogs
|
|
19
|
+
from .audit import Audit, run
|
|
20
|
+
from .rules import HIGH, LOW, MEDIUM
|
|
21
|
+
|
|
22
|
+
SEV_STYLE = {HIGH: "bold red", MEDIUM: "yellow", LOW: "dim"}
|
|
23
|
+
SEV_LABEL = {HIGH: "high", MEDIUM: "medium", LOW: "low"}
|
|
24
|
+
AGENT_NAME = {"claude": "Claude Code", "codex": "Codex", "gemini": "Gemini CLI"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def short_path(p: str) -> str:
|
|
28
|
+
home = str(Path.home())
|
|
29
|
+
return "~" + p[len(home):] if p.startswith(home) else p
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def when(ts: float | None) -> str:
|
|
33
|
+
if not ts:
|
|
34
|
+
return ""
|
|
35
|
+
d = time.time() - ts
|
|
36
|
+
if d < 3600:
|
|
37
|
+
return f"{max(1, int(d // 60))}m ago"
|
|
38
|
+
if d < 86400:
|
|
39
|
+
return f"{int(d // 3600)}h ago"
|
|
40
|
+
return time.strftime("%b %d", time.localtime(ts))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def one_line(s: str, n: int = 70) -> str:
|
|
44
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
45
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render(a: Audit, console: Console, show: int = 6, include_low: bool = False) -> None:
|
|
49
|
+
if not a.actions:
|
|
50
|
+
console.print(Text("No agent activity found. agentsnitch reads Claude Code, Codex and Gemini CLI logs from "
|
|
51
|
+
"your home folder; if you use them, try --since all.", "yellow"))
|
|
52
|
+
return
|
|
53
|
+
head = Text.assemble("Your coding agents ran ", (f"{a.commands:,} commands", "bold"), f" {a.period()}",
|
|
54
|
+
f", read {a.reads:,} files and wrote {a.writes:,}.")
|
|
55
|
+
n_s, n_p = len(a.sessions), len(a.projects)
|
|
56
|
+
sub = Text(f"{n_s} session{'s' * (n_s != 1)} · {n_p} project{'s' * (n_p != 1)} · " +
|
|
57
|
+
" · ".join(f"{AGENT_NAME.get(k, k)} {v:,}" for k, v in a.agents.most_common() if v), "dim")
|
|
58
|
+
parts: list = [head, sub, Text("")]
|
|
59
|
+
hl = a.highlights()
|
|
60
|
+
if hl:
|
|
61
|
+
parts += [Text(" · ".join(hl), "bold red"), Text("")]
|
|
62
|
+
|
|
63
|
+
rows = [r for r in a.by_rule() if include_low or r[1] != LOW]
|
|
64
|
+
if rows:
|
|
65
|
+
t = Table(box=None, header_style="dim", padding=(0, 2), expand=True)
|
|
66
|
+
t.add_column("", no_wrap=True)
|
|
67
|
+
t.add_column("What", no_wrap=True)
|
|
68
|
+
t.add_column("Times", justify="right")
|
|
69
|
+
t.add_column("Latest", ratio=1, no_wrap=True, overflow="ellipsis")
|
|
70
|
+
for title, sev, n, f in rows:
|
|
71
|
+
t.add_row(Text(SEV_LABEL[sev], SEV_STYLE[sev]), Text(title, "bold" if sev == HIGH else ""), str(n),
|
|
72
|
+
Text.assemble((when(f.ts) + " ", "dim"), one_line(short_path(f.detail), 80)))
|
|
73
|
+
parts += [t, Text("")]
|
|
74
|
+
else:
|
|
75
|
+
parts += [Text("Nothing risky: no force-pushes, no curl | sh, no secrets, nothing outside your projects.",
|
|
76
|
+
"green"), Text("")]
|
|
77
|
+
|
|
78
|
+
serious = sorted([f for f in a.findings if f.severity == HIGH], key=lambda f: -(f.ts or 0))[:show]
|
|
79
|
+
if serious:
|
|
80
|
+
g = Table(title="Most recent high-risk actions", title_justify="left", title_style="bold", box=None,
|
|
81
|
+
show_header=False, padding=(0, 2), expand=True)
|
|
82
|
+
g.add_column(no_wrap=True, style="dim")
|
|
83
|
+
g.add_column(no_wrap=True)
|
|
84
|
+
g.add_column(no_wrap=True, style="dim")
|
|
85
|
+
g.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
|
|
86
|
+
for f in serious:
|
|
87
|
+
g.add_row(when(f.ts), AGENT_NAME.get(f.agent, f.agent), os.path.basename(f.project.rstrip("/\\")) or "?",
|
|
88
|
+
Text(one_line(short_path(f.detail), 90), "red"))
|
|
89
|
+
parts += [g, Text("")]
|
|
90
|
+
|
|
91
|
+
if a.top_commands:
|
|
92
|
+
parts.append(Text("Most-run: " + ", ".join(f"{c} ({n})" for c, n in a.top_commands.most_common(8)), "dim"))
|
|
93
|
+
console.print(Panel(Group(*parts), title="[bold]What did your AI agents do?[/]",
|
|
94
|
+
border_style="red" if any(f.severity == HIGH for f in a.findings) else "green", padding=(1, 2)))
|
|
95
|
+
console.print(Text(" Share: ", "dim") + Text(a.share(), "italic"))
|
|
96
|
+
console.print()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_since(s: str) -> float | None:
|
|
100
|
+
if s == "all":
|
|
101
|
+
return None
|
|
102
|
+
m = re.fullmatch(r"(\d+)\s*([hdwm])", s)
|
|
103
|
+
if m:
|
|
104
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
105
|
+
return time.time() - n * {"h": 3600, "d": 86400, "w": 7 * 86400, "m": 30 * 86400}[unit]
|
|
106
|
+
from datetime import datetime
|
|
107
|
+
try:
|
|
108
|
+
return time.mktime(datetime.strptime(s, "%Y-%m-%d").timetuple())
|
|
109
|
+
except ValueError:
|
|
110
|
+
raise argparse.ArgumentTypeError("use e.g. 7d, 30d, 12h, 2026-10-01 or all")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(argv: list[str] | None = None) -> int:
|
|
114
|
+
ap = argparse.ArgumentParser(prog="agentsnitch", description="See every command your AI coding agents ran and "
|
|
115
|
+
"every file they touched, with the risky ones flagged. Reads Claude Code, Codex and "
|
|
116
|
+
"Gemini CLI logs locally.")
|
|
117
|
+
ap.add_argument("--since", type=parse_since, default=parse_since("30d"), help="7d, 30d (default), 12h, a date, or all")
|
|
118
|
+
ap.add_argument("--project", help="only sessions whose folder contains this text")
|
|
119
|
+
ap.add_argument("--agent", choices=["claude", "codex", "gemini"], help="only one agent")
|
|
120
|
+
ap.add_argument("--low", action="store_true", help="also show low-severity findings")
|
|
121
|
+
ap.add_argument("--log", action="store_true", help="print every command and file action, oldest first")
|
|
122
|
+
ap.add_argument("--json", action="store_true", help="findings as JSON")
|
|
123
|
+
ap.add_argument("-V", "--version", action="version", version=f"agentsnitch {__version__}")
|
|
124
|
+
a = ap.parse_args(argv)
|
|
125
|
+
console = Console(stderr=a.json)
|
|
126
|
+
|
|
127
|
+
def keep(ev):
|
|
128
|
+
return (not a.agent or ev.agent == a.agent) and (not a.project or a.project.lower() in (ev.cwd or "").lower())
|
|
129
|
+
|
|
130
|
+
events = [ev for ev in agentlogs.all_events(since=a.since) if keep(ev)]
|
|
131
|
+
if a.log:
|
|
132
|
+
for ev in sorted(events, key=lambda e: e.ts or 0):
|
|
133
|
+
if ev.kind in ("command", "read", "write", "edit", "fetch"):
|
|
134
|
+
what = ev.command or ev.path or ev.text
|
|
135
|
+
console.print(Text.assemble((time.strftime("%Y-%m-%d %H:%M", time.localtime(ev.ts or 0)) + " ", "dim"),
|
|
136
|
+
(f"{ev.agent:7} {ev.kind:7} ", "cyan"), one_line(short_path(what), 200)))
|
|
137
|
+
return 0
|
|
138
|
+
audit = run(events, a.since)
|
|
139
|
+
if a.json:
|
|
140
|
+
print(json.dumps({"since": a.since, "commands": audit.commands, "reads": audit.reads, "writes": audit.writes,
|
|
141
|
+
"sessions": len(audit.sessions), "highlights": audit.highlights(),
|
|
142
|
+
"findings": [asdict(f) for f in audit.findings],
|
|
143
|
+
"outside_writes": [asdict(f) for f in audit.outside_writes],
|
|
144
|
+
"outside_reads": [asdict(f) for f in audit.outside_reads]}, indent=2))
|
|
145
|
+
else:
|
|
146
|
+
render(audit, console, include_low=a.low)
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
sys.exit(main())
|
agentsnitch/rules.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""What counts as risky when an AI agent runs it on your machine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
HIGH, MEDIUM, LOW = "high", "medium", "low"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Rule:
|
|
13
|
+
key: str
|
|
14
|
+
title: str # short, for tables
|
|
15
|
+
severity: str
|
|
16
|
+
pattern: re.Pattern
|
|
17
|
+
why: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def R(key, title, severity, pattern, why):
|
|
21
|
+
return Rule(key, title, severity, re.compile(pattern, re.I | re.M), why)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Patterns match anywhere in the command line, including inside `bash -c "..."` and pipelines.
|
|
25
|
+
COMMAND_RULES = [
|
|
26
|
+
# --- remote code ---------------------------------------------------------------------------------
|
|
27
|
+
R("pipe-to-shell", "Piped a download into a shell", HIGH,
|
|
28
|
+
r"(curl|wget|iwr|invoke-webrequest|irm|invoke-restmethod)\b[^|;&]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b|"
|
|
29
|
+
r"\|\s*(sudo\s+)?python3?\b\s*(-\s*)?$|\b(iex|invoke-expression)\b",
|
|
30
|
+
"runs whatever that URL serves, unread"),
|
|
31
|
+
R("eval-remote", "eval'd a download", HIGH, r"\beval\s+[\"']?\$\((curl|wget)", "runs whatever that URL serves"),
|
|
32
|
+
# --- destruction ----------------------------------------------------------------------------------
|
|
33
|
+
R("rm-root", "rm -rf on home or root", HIGH,
|
|
34
|
+
r"\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*|--recursive\s+--force|-r\s+-f|-f\s+-r)\s+"
|
|
35
|
+
r"(\"?(~|\$home|/|/\*|\.\.)\"?/?(\s|$))",
|
|
36
|
+
"one typo away from deleting everything"),
|
|
37
|
+
R("rm-rf", "rm -rf", LOW,
|
|
38
|
+
r"\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*|--recursive\s+--force|-r\s+-f|-f\s+-r)\b",
|
|
39
|
+
"recursive force delete"),
|
|
40
|
+
R("force-push", "Force-pushed", HIGH, r"\bgit\b[^;&|]*\bpush\b[^;&|]*(\s--force\b|\s-f\b|\s--force-with-lease\b|\s\+\S)",
|
|
41
|
+
"rewrites shared history on the remote"),
|
|
42
|
+
R("reset-hard", "git reset --hard / clean", MEDIUM, r"\bgit\b[^;&|]*\b(reset\s+--hard|clean\s+-[a-z]*f|checkout\s+--\s+\.|"
|
|
43
|
+
r"restore\s+(--staged\s+)?\.(\s|$)|stash\s+(drop|clear))",
|
|
44
|
+
"throws away uncommitted work"),
|
|
45
|
+
R("drop-db", "Dropped or wiped a database", HIGH, r"\b(drop\s+(table|database|schema)|truncate\s+table|dropdb\b|"
|
|
46
|
+
r"flushall\b|flushdb\b|db\.dropDatabase)", "data loss"),
|
|
47
|
+
R("disk", "Low-level disk command", HIGH, r"\b(mkfs(\.\w+)?|dd\s+[^|;&]*\bof=/dev/|diskutil\s+(erase|partition)|"
|
|
48
|
+
r"fdisk|parted)\b", "can wipe a disk"),
|
|
49
|
+
R("infra-destroy", "Destroyed infrastructure", HIGH, r"\b(terraform\s+destroy|kubectl\s+delete|helm\s+uninstall|"
|
|
50
|
+
r"aws\s+\S+\s+(delete|terminate|rm)\S*|gcloud\s+.*\bdelete\b|docker\s+(system\s+prune|volume\s+rm|rm\s+-f))",
|
|
51
|
+
"deletes real resources"),
|
|
52
|
+
R("chmod-777", "chmod 777", MEDIUM, r"\bchmod\s+(-R\s+)?(0?777|a\+rwx)\b", "makes files writable by anyone"),
|
|
53
|
+
# --- secrets --------------------------------------------------------------------------------------
|
|
54
|
+
R("ssh-keys", "Touched SSH keys", HIGH, r"\.ssh/(id_[\w-]+|identity|[\w.-]*_(rsa|ed25519|ecdsa|dsa)|[\w.-]+\.pem)"
|
|
55
|
+
r"(?!\.pub)\b(?!\.pub)", "private keys"),
|
|
56
|
+
R("cloud-creds", "Read cloud credentials", HIGH, r"\.aws/credentials|\.config/gcloud/(credentials|application_default)|"
|
|
57
|
+
r"\.azure/|\.kube/config|\.docker/config\.json|\.netrc|\.npmrc|\.pypirc|\.git-credentials", "credentials"),
|
|
58
|
+
R("keychain", "Read the keychain / password store", HIGH, r"\bsecurity\s+(find|dump)-(generic|internet)-password|"
|
|
59
|
+
r"\bsecret-tool\s+lookup|\bpass\s+show\b|\bop\s+(read|item\s+get)\b|\bgh\s+auth\s+token\b|cmdkey\s+/list",
|
|
60
|
+
"pulls secrets out of your password store"),
|
|
61
|
+
R("dotenv", "Read a .env file", MEDIUM, r"(^|[\s/'\"])\.env(\.\w+)?\b(?!\.example|\.sample|\.template)",
|
|
62
|
+
"usually holds API keys"),
|
|
63
|
+
R("env-dump", "Dumped all environment variables", MEDIUM, r"(^|[;&|]\s*)(printenv|env|set|export\s+-p)\s*($|[|>;&])",
|
|
64
|
+
"prints every secret in your environment"),
|
|
65
|
+
# --- sending things out -------------------------------------------------------------------------
|
|
66
|
+
R("upload", "Sent data to a server", MEDIUM, r"\bcurl\b(?![^|;&]*\s(-G|--get)\b)[^|;&]*(\s-d\s|\s--data(-binary|-raw|-urlencode)?\b|\s-F\s|"
|
|
67
|
+
r"\s--form\b|\s-T\s|\s--upload-file\b|\s-X\s*(POST|PUT|PATCH)\b)|\bwget\b[^|;&]*--post-(data|file)|"
|
|
68
|
+
r"\b(nc|ncat|netcat|socat)\b\s+\S+\s+\d+", "data leaving your machine"),
|
|
69
|
+
R("remote-copy", "Copied files to another machine", MEDIUM, r"\b(scp|rsync|sftp)\b[^|;&]*\S+@?[\w.-]+:", "files leaving"),
|
|
70
|
+
R("ssh", "SSH'd somewhere", LOW, r"(^|[;&|]\s*)ssh\s+(-\S+\s+)*[\w.-]*@?[\w.-]+\.\w+", "remote access"),
|
|
71
|
+
# --- shipping / irreversible ---------------------------------------------------------------------
|
|
72
|
+
R("publish", "Published a package", HIGH, r"\b(npm|pnpm|yarn)\s+publish\b|\btwine\s+upload\b|\b(uv|poetry|flit|hatch)"
|
|
73
|
+
r"\s+publish\b|\bcargo\s+publish\b|\bgem\s+push\b|\bdocker\s+push\b|\bgh\s+release\s+create\b",
|
|
74
|
+
"ships to the world, can't unship"),
|
|
75
|
+
R("push", "git push", LOW, r"\bgit\b[^;&|]*\bpush\b", "changes the remote"),
|
|
76
|
+
R("merge-pr", "Merged a PR", MEDIUM, r"\bgh\s+pr\s+merge\b", "lands code on someone's main branch"),
|
|
77
|
+
R("deploy", "Deployed", MEDIUM, r"\b(vercel\s+(--prod|deploy)|netlify\s+deploy|fly\s+deploy|kubectl\s+apply|"
|
|
78
|
+
r"terraform\s+apply|serverless\s+deploy|firebase\s+deploy|wrangler\s+(deploy|publish))\b", "changes production"),
|
|
79
|
+
# --- your system --------------------------------------------------------------------------------
|
|
80
|
+
R("sudo", "Used sudo", MEDIUM, r"(^|[;&|]\s*|\s)sudo\s", "root on your machine"),
|
|
81
|
+
R("persistence", "Set up something that runs on its own", MEDIUM, r"\b(crontab\s+(-e|-|\S+\.txt)|launchctl\s+(load|"
|
|
82
|
+
r"bootstrap)|systemctl\s+(--user\s+)?enable|schtasks\s+/create)\b|Library/LaunchAgents", "keeps running later"),
|
|
83
|
+
R("shell-rc", "Edited your shell startup files", MEDIUM, r"(>>?|tee(\s+-a)?)\s*\S*(\.zshrc|\.bashrc|\.bash_profile|"
|
|
84
|
+
r"\.profile|\.zprofile|config\.fish)\b", "runs in every terminal you open"),
|
|
85
|
+
R("global-install", "Installed software", LOW, r"\b(npm|pnpm|yarn)\s+(i|install|add)\s+(-g|--global)\b|"
|
|
86
|
+
r"\bbrew\s+install\b|\bapt(-get)?\s+install\b|\bpip3?\s+install\b(?![^;&|]*(-r\s|--requirement|-e\s|\.))",
|
|
87
|
+
"new software outside the project"),
|
|
88
|
+
R("kill", "Killed processes broadly", LOW, r"\b(pkill|killall)\b|\bkill\s+-9\s+-1\b", "can take down other apps"),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
SENSITIVE_PATHS = [
|
|
92
|
+
(re.compile(r"/\.ssh/(?!.*\.pub$)(?!known_hosts|config$|authorized_keys)"), "ssh-keys", "Touched SSH keys", HIGH),
|
|
93
|
+
(re.compile(r"/\.aws/|/\.config/gcloud/|/\.azure/|/\.kube/config|/\.docker/config\.json|/\.netrc$|/\.npmrc$|"
|
|
94
|
+
r"/\.pypirc$|/\.git-credentials$"), "cloud-creds", "Read cloud credentials", HIGH),
|
|
95
|
+
(re.compile(r"(^|/)\.env(\.[\w-]+)?$"), "dotenv", "Read a .env file", MEDIUM),
|
|
96
|
+
(re.compile(r"/(Library/Keychains|\.gnupg|\.password-store)/"), "keychain", "Read the keychain / password store", HIGH),
|
|
97
|
+
(re.compile(r"/(\.zshrc|\.bashrc|\.bash_profile|\.profile|\.zprofile)$"), "shell-rc",
|
|
98
|
+
"Edited your shell startup files", MEDIUM),
|
|
99
|
+
]
|
|
100
|
+
DOTENV_OK = re.compile(r"\.env\.(example|sample|template|dist)$")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1[^\n]*\n.*?\n\s*\2\s*(\n|$)", re.S)
|
|
104
|
+
INLINE_SCRIPT = re.compile(r"\b(python[\d.]*|node|ruby|perl|php|deno\s+eval)\s+(-[a-zA-Z]*\s+)*-[ce]\s+"
|
|
105
|
+
r"(\"(\\.|[^\"\\])*\"|'[^']*')", re.S)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def shell_only(cmd: str) -> str:
|
|
109
|
+
"""Drop heredoc bodies and inline Python/Node scripts: their text isn't run by the shell, so a test file that
|
|
110
|
+
mentions `curl | sh` shouldn't count as running it. (bash -c "..." stays: that IS shell.)"""
|
|
111
|
+
cmd = HEREDOC.sub(lambda m: m.group(0).split("\n", 1)[0] + "\n", cmd)
|
|
112
|
+
return INLINE_SCRIPT.sub(lambda m: m.group(1) + " -c …", cmd)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def classify_command(cmd: str) -> list[tuple[Rule, str]]:
|
|
116
|
+
"""[(rule, the part of the command that matched)]"""
|
|
117
|
+
cmd = shell_only(cmd)
|
|
118
|
+
hits: list[tuple[Rule, str]] = []
|
|
119
|
+
keys = set()
|
|
120
|
+
for r in COMMAND_RULES:
|
|
121
|
+
m = r.pattern.search(cmd)
|
|
122
|
+
if m:
|
|
123
|
+
# a specific rule hides its generic cousin
|
|
124
|
+
if r.key == "rm-rf" and "rm-root" in keys or r.key == "push" and "force-push" in keys:
|
|
125
|
+
continue
|
|
126
|
+
a, b = max(0, m.start() - 25), min(len(cmd), m.end() + 35)
|
|
127
|
+
snippet = ("…" if a else "") + cmd[a:b].strip() + ("…" if b < len(cmd) else "")
|
|
128
|
+
hits.append((r, snippet))
|
|
129
|
+
keys.add(r.key)
|
|
130
|
+
return hits
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def classify_path(path: str, kind: str) -> list[tuple[str, str, str]]:
|
|
134
|
+
p = path.replace("\\", "/")
|
|
135
|
+
out = []
|
|
136
|
+
for rx, key, title, sev in SENSITIVE_PATHS:
|
|
137
|
+
if rx.search(p) and not (key == "dotenv" and DOTENV_OK.search(p)):
|
|
138
|
+
if key == "shell-rc" and kind not in ("write", "edit"):
|
|
139
|
+
continue
|
|
140
|
+
out.append((key, title, sev))
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _norm(path: str) -> str:
|
|
145
|
+
p = path.replace("\\", "/")
|
|
146
|
+
p = re.sub(r"/+", "/", p)
|
|
147
|
+
parts: list[str] = []
|
|
148
|
+
for seg in p.split("/"):
|
|
149
|
+
if seg == "..":
|
|
150
|
+
if parts and parts[-1] not in ("", ".."):
|
|
151
|
+
parts.pop()
|
|
152
|
+
elif seg != ".":
|
|
153
|
+
parts.append(seg)
|
|
154
|
+
out = "/".join(parts)
|
|
155
|
+
return out.lower() if re.match(r"^[a-z]:", out, re.I) else out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
TEMP = re.compile(r"^(/tmp|/private/tmp|/var/folders|/private/var/folders|/dev/null)(/|$)|/temp/|/appdata/local/temp/",
|
|
159
|
+
re.I)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def outside(path: str, project: str | None) -> bool:
|
|
163
|
+
"""Did the agent touch a file outside the folder it was started in? (Temp folders don't count.)"""
|
|
164
|
+
if not project or not path:
|
|
165
|
+
return False
|
|
166
|
+
p = _norm(path)
|
|
167
|
+
if not (p.startswith("/") or re.match(r"^[a-z]:/", p)):
|
|
168
|
+
return False # relative paths are inside the project
|
|
169
|
+
root = _norm(project).rstrip("/")
|
|
170
|
+
if p == root or p.startswith(root + "/"):
|
|
171
|
+
return False
|
|
172
|
+
return not TEMP.search(p)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentsnitch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: What did your AI coding agents actually do? Every command Claude Code, Codex and Gemini CLI ran on your machine, with force-pushes, curl | sh, secret reads and files outside the project flagged.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ArkaKhorchidian/agentsnitch
|
|
6
|
+
Project-URL: Issues, https://github.com/ArkaKhorchidian/agentsnitch/issues
|
|
7
|
+
Author: Arka Khorchidian
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,audit,claude-code,cli,codex,coding-agents,devsecops,gemini-cli,llm,security
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: rich>=13
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# agentsnitch
|
|
25
|
+
|
|
26
|
+
**What did your AI coding agents actually do?** agentsnitch reads the session logs that Claude Code, Codex and Gemini CLI keep on your machine and shows every command they ran and every file they touched. The ones worth a second look are flagged: force-pushes, `curl | sh`, reads of your SSH keys and `.env` files, and writes outside the project.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uvx agentsnitch
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
<p align="center"><img src="https://raw.githubusercontent.com/ArkaKhorchidian/agentsnitch/main/docs/demo.gif" alt="agentsnitch demo" width="760"></p>
|
|
33
|
+
|
|
34
|
+
<sub>Demo on a month of made-up agent logs generated by <a href="docs/demo.py">docs/demo.py</a>. Run it to see what your own agents did.</sub>
|
|
35
|
+
|
|
36
|
+
You approve commands one at a time, or you turned on auto-approve weeks ago. Either way, nobody reads the transcript afterwards. agentsnitch does.
|
|
37
|
+
|
|
38
|
+
## What it flags
|
|
39
|
+
|
|
40
|
+
| | Examples |
|
|
41
|
+
|---|---|
|
|
42
|
+
| **Remote code** | `curl … \| sh`, `wget … \| bash`, `iwr … \| iex`, `eval $(curl …)` |
|
|
43
|
+
| **Destruction** | `rm -rf ~/`, `git push --force`, `git reset --hard`, `DROP TABLE`, `terraform destroy`, `kubectl delete`, `aws s3 rm` |
|
|
44
|
+
| **Secrets** | private SSH keys, `~/.aws/credentials` and other cloud credentials, `.env` files, the macOS keychain / `pass` / 1Password CLI, `gh auth token`, dumping the whole environment |
|
|
45
|
+
| **Data leaving** | `curl -X POST -d @file`, `scp`/`rsync` to other machines, `nc` |
|
|
46
|
+
| **Shipping** | `npm publish`, `twine upload`, `docker push`, `gh release create`, `gh pr merge`, deploys |
|
|
47
|
+
| **Your system** | `sudo`, edits to `.zshrc`/`.bashrc`, cron jobs and launch agents, files written outside the project folder |
|
|
48
|
+
|
|
49
|
+
Text that's only *inside* a script (a heredoc, `python -c "…"`) isn't counted as run. `bash -c "…"` is, because that is shell.
|
|
50
|
+
|
|
51
|
+
## Where it reads
|
|
52
|
+
|
|
53
|
+
| Agent | Logs |
|
|
54
|
+
|---|---|
|
|
55
|
+
| Claude Code | `~/.claude/projects/*/*.jsonl` (or `$CLAUDE_CONFIG_DIR`) |
|
|
56
|
+
| Codex CLI | `~/.codex/sessions/**/rollout-*.jsonl` (or `$CODEX_HOME`) |
|
|
57
|
+
| Gemini CLI | `~/.gemini/tmp/*/chats/session-*` |
|
|
58
|
+
|
|
59
|
+
Cursor keeps its history in a SQLite database, which isn't supported yet.
|
|
60
|
+
|
|
61
|
+
## Options
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
agentsnitch # last 30 days
|
|
65
|
+
agentsnitch --since 7d # or 12h, 2026-10-01, all
|
|
66
|
+
agentsnitch --project storefront
|
|
67
|
+
agentsnitch --agent codex
|
|
68
|
+
agentsnitch --low # also show low-severity things (git push, rm -rf in the project, installs)
|
|
69
|
+
agentsnitch --log # every command and file action, oldest first
|
|
70
|
+
agentsnitch --json # for scripts
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Privacy
|
|
74
|
+
|
|
75
|
+
agentsnitch only reads files on your machine and sends nothing anywhere.
|
|
76
|
+
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uvx agentsnitch # run without installing
|
|
81
|
+
pipx install agentsnitch # or install it
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Requires Python 3.9+. The only dependency is `rich`.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
agentsnitch/__init__.py,sha256=VBLc7FIDkfph6iDU7qdUTWZluEgKSFt-qBr14tCgjp4,88
|
|
2
|
+
agentsnitch/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
agentsnitch/agentlogs.py,sha256=KZ-w1mVwluDN0pBELaVm84H_kNJjp83UUwzOHbFRYig,15970
|
|
4
|
+
agentsnitch/audit.py,sha256=qcvr3XaEDGQa0xo0fqct3fBsFsz6MQP_n9jljR0jO-A,6624
|
|
5
|
+
agentsnitch/cli.py,sha256=cPv-lhtjnGcVoSlbRC1AkrK2jWQG9PqQXB8M2vqBMLc,6998
|
|
6
|
+
agentsnitch/rules.py,sha256=ZdPCxbpzGjeUM5n0gdQjvBxgqYLrrK6o4IkvhaCIKFs,9876
|
|
7
|
+
agentsnitch-0.1.0.dist-info/METADATA,sha256=RlPn1T9fjKa6Ljja4hr87ebvm5e5NaiG6hkExSA0tF4,3777
|
|
8
|
+
agentsnitch-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
agentsnitch-0.1.0.dist-info/entry_points.txt,sha256=DmUjgrzJrxbTI4_7siQvpFu-mCS1oHtKgqpbnz7bN_Q,53
|
|
10
|
+
agentsnitch-0.1.0.dist-info/licenses/LICENSE,sha256=mxFQpL6plnvuVMkJNW-M1_rxIlAzORcfDhnP1UETuKI,1073
|
|
11
|
+
agentsnitch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arka Khorchidian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|