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.
@@ -0,0 +1,2 @@
1
+ """What did your AI coding agents actually do on your machine?"""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentsnitch = agentsnitch.cli:main
@@ -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.