ephor 0.3.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.
ephor/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Ephor — a passive, dependency-light monitor for what AI agents do on a Linux box.
2
+
3
+ No proxy, no agent, no daemon-in-your-traffic: it reads /proc and the socket table, attributes every outbound
4
+ connection to the agent that made it, records where each agent went and how much it pulled down, flags
5
+ known-bad destinations against public threat feeds, and shows what each agent can actually access. Detect-only.
6
+ """
7
+ __version__ = "0.3.0"
ephor/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
ephor/classify.py ADDED
@@ -0,0 +1,136 @@
1
+ """Attribute a process to an *agent* — generically and configurably.
2
+
3
+ The goal is to turn a raw process (command line, executable, owning user) into a stable agent label like
4
+ ``hermes:coder``, ``claude``, ``ollama`` or ``langchain:myscript``. None of this is hard-coded to one estate:
5
+ built-in patterns cover the common AI runtimes, a config file adds your own, and anything unrecognised still
6
+ gets a deterministic ``other:<exe>`` label so nothing slips through unseen.
7
+
8
+ Classification order:
9
+ 1. user-supplied patterns from config (``[[agents]] pattern = "...", name = "..."``)
10
+ 2. built-in named runtimes (claude, cursor, ollama, the python LLM frameworks, node agents, ...)
11
+ 3. python fallback (``python -m pkg`` -> ``py:pkg``; ``python foo.py`` -> ``py:foo``)
12
+ 4. last resort (``other:<basename of exe>``)
13
+
14
+ The ignore list drops things that are never an "AI agent doing something" — sshd, systemd, cron, the kernel,
15
+ and (by default) Claude Code itself, so Ephor never reports on the tool you are reading this with.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+
22
+ from . import config
23
+
24
+ # Command lines we never track. Entries are substrings, or /regex/ when wrapped in slashes.
25
+ IGNORE_DEFAULT = [
26
+ r"/(^|/)sshd?\b/",
27
+ r"/\bsystemd(-\w+)?\b/",
28
+ r"/(^|/)(cron|crond|dbus-daemon|rsyslogd|chronyd|agetty|login)\b/",
29
+ r"/\bclaude-code\b/",
30
+ r"@anthropic-ai/claude-code",
31
+ # mesh/VPN and monitoring daemons: real egress, but not AI agents — pure noise. Override via config if wanted.
32
+ r"/\b(tailscaled|netbird|wireguard|zerotier-one)\b/",
33
+ r"/\b(node_exporter|beszel-agent|beszel|prometheus|telegraf|netdata)\b/",
34
+ ]
35
+
36
+ # (compiled cmdline regex, label) — tried in order. {g1} means "use the first capture group, basename-ised".
37
+ _BUILTINS: list[tuple[re.Pattern, str]] = [
38
+ (re.compile(r"\bhermes\b.*?--profile[= ]+(\S+)"), "hermes:{g1}"),
39
+ (re.compile(r"\bhermes\b"), "hermes"),
40
+ (re.compile(r"\bopencode\b"), "opencode"),
41
+ (re.compile(r"\bollama\b"), "ollama"),
42
+ (re.compile(r"@anthropic-ai/claude\b|\bclaude-desktop\b"), "claude"),
43
+ (re.compile(r"\bcursor\b|\bcursor-server\b"), "cursor"),
44
+ (re.compile(r"\bchatgpt\b|\bchatgpt-desktop\b"), "chatgpt-desktop"),
45
+ (re.compile(r"\b(aider)\b"), "aider"),
46
+ (re.compile(r"\b(crawl4ai|playwright|puppeteer)\b"), "scraper"),
47
+ # python LLM frameworks: label by the framework so many scripts collapse to one agent
48
+ (re.compile(r"\b(langchain|langgraph)\b"), "langchain"),
49
+ (re.compile(r"\bcrewai\b"), "crewai"),
50
+ (re.compile(r"\b(autogen|pyautogen|ag2)\b"), "autogen"),
51
+ (re.compile(r"\bllama_index\b|\bllama-index\b"), "llamaindex"),
52
+ (re.compile(r"\bsemantic_kernel\b"), "semantic-kernel"),
53
+ (re.compile(r"\bhaystack\b"), "haystack"),
54
+ ]
55
+
56
+ _PY = re.compile(r"\bpython[0-9.]*\b")
57
+ _PY_M = re.compile(r"-m[= ]+([A-Za-z0-9_.]+)")
58
+ _PY_FILE = re.compile(r"(\S+\.py)\b")
59
+ _NODE = re.compile(r"\bnode\b")
60
+
61
+
62
+ def _ignore_patterns() -> list:
63
+ out = []
64
+ for raw in IGNORE_DEFAULT + list(config.CONF.get("ignore") or []):
65
+ if len(raw) >= 2 and raw[0] == "/" and raw[-1] == "/":
66
+ try:
67
+ out.append(("re", re.compile(raw[1:-1])))
68
+ except re.error:
69
+ continue
70
+ else:
71
+ out.append(("sub", raw))
72
+ return out
73
+
74
+
75
+ _IGNORE = _ignore_patterns()
76
+
77
+
78
+ def _user_patterns() -> list[tuple[re.Pattern, str]]:
79
+ out = []
80
+ for item in config.CONF.get("agents") or []:
81
+ pat, name = item.get("pattern"), item.get("name")
82
+ if pat and name:
83
+ try:
84
+ out.append((re.compile(pat), name))
85
+ except re.error:
86
+ continue
87
+ return out
88
+
89
+
90
+ _USER = _user_patterns()
91
+
92
+
93
+ def ignored(cmd: str) -> bool:
94
+ for kind, pat in _IGNORE:
95
+ if kind == "sub" and pat in cmd:
96
+ return True
97
+ if kind == "re" and pat.search(cmd):
98
+ return True
99
+ return False
100
+
101
+
102
+ def _apply(label: str, m: re.Match) -> str:
103
+ if "{g1}" in label:
104
+ g = (m.group(1) if m.groups() else "") or ""
105
+ g = os.path.basename(g.strip("/")) or "default"
106
+ return label.replace("{g1}", g)
107
+ return label
108
+
109
+
110
+ def classify(cmd: str, exe: str = "") -> str:
111
+ """Map a command line (and optional exe path) to an agent label. Never raises."""
112
+ cmd = (cmd or "").strip()
113
+ for pat, label in _USER:
114
+ m = pat.search(cmd)
115
+ if m:
116
+ return _apply(label, m)
117
+ for pat, label in _BUILTINS:
118
+ m = pat.search(cmd)
119
+ if m:
120
+ return _apply(label, m)
121
+ if _PY.search(cmd) or (exe and "python" in os.path.basename(exe)):
122
+ m = _PY_M.search(cmd)
123
+ if m:
124
+ return "py:" + m.group(1).split(".")[0]
125
+ m = _PY_FILE.search(cmd)
126
+ if m:
127
+ return "py:" + os.path.splitext(os.path.basename(m.group(1)))[0]
128
+ return "py:repl"
129
+ if _NODE.search(cmd) or (exe and os.path.basename(exe) in ("node", "nodejs")):
130
+ m = _PY_FILE.search(cmd.replace(".py", ".js")) or re.search(r"(\S+\.(?:js|mjs|cjs|ts))\b", cmd)
131
+ if m:
132
+ return "node:" + os.path.splitext(os.path.basename(m.group(1)))[0]
133
+ return "node"
134
+ base = os.path.basename(exe) if exe else (cmd.split() or ["unknown"])[0]
135
+ base = os.path.basename(base.strip("/")) or "unknown"
136
+ return "other:" + base
ephor/cli.py ADDED
@@ -0,0 +1,455 @@
1
+ """Command line. Detect-only: Ephor reports, it never blocks, kills or firewalls anything.
2
+
3
+ ephor watch [--resolve] [--no-feeds] take one sample, attribute it, fold it into history
4
+ ephor watch --loop [--interval N] re-sample on an interval, alerting on new/dangerous activity
5
+ ephor agents every agent seen, with destination count and bytes pulled
6
+ ephor agent <name> [--perms] one agent's destinations (and, with --perms, its blast radius)
7
+ ephor history [--agent A] [--bad] the destination log, newest first
8
+ ephor status database + feed summary
9
+ ephor feeds [--refresh] threat-feed cache state
10
+ ephor serve [--host] [--port] read-only HTML view (stdlib only; LAN use)
11
+
12
+ Add --json to any read command for machine-readable output. `watch` exits non-zero if a connection to a
13
+ known-bad destination was seen in that sample, so it drops straight into cron or a CI step.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ import time
22
+
23
+ from . import __version__, collect, config, feeds, perms, resolve, store, virustotal
24
+
25
+ EXIT_OK = 0
26
+ EXIT_BAD = 2 # a known-bad destination was observed
27
+
28
+
29
+ def _age(ts: int) -> str:
30
+ if not ts:
31
+ return "never"
32
+ d = int(time.time()) - ts
33
+ for unit, n in (("d", 86400), ("h", 3600), ("m", 60)):
34
+ if d >= n:
35
+ return f"{d // n}{unit} ago"
36
+ return f"{d}s ago"
37
+
38
+
39
+ def _fmt_bytes(n: int) -> str:
40
+ n = float(n or 0)
41
+ for unit in ("B", "KB", "MB", "GB", "TB"):
42
+ if n < 1024:
43
+ return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
44
+ n /= 1024
45
+ return f"{n:.1f}PB"
46
+
47
+
48
+ def cmd_watch(args) -> int:
49
+ if args.loop:
50
+ from . import watch
51
+ return watch.run_loop(args)
52
+ s = collect.sample()
53
+ if not s.have_ss:
54
+ print("ephor: 'ss' (iproute2) not found — cannot read the socket table", file=sys.stderr)
55
+ return 1
56
+ if not s.is_root:
57
+ print("ephor: not running as root — only connections owned by this user are attributable",
58
+ file=sys.stderr)
59
+ conns = collect.attribute(s)
60
+ matcher = None if args.no_feeds else feeds.Matcher()
61
+ vt = None
62
+ if virustotal.enabled(getattr(args, "virustotal", False)):
63
+ vt = virustotal.VirusTotal()
64
+ print(virustotal.PRIVACY_WARNING, file=sys.stderr)
65
+ vt_budget = 3 # at most a few VT lookups per run, so a busy box never blows the free-tier quota
66
+ db = store.connect()
67
+ bad_seen = False
68
+ agents_seen = set()
69
+ with db:
70
+ for c in conns:
71
+ agents_seen.add(c.agent)
72
+ dest = resolve.ptr(c.rip) if args.resolve else c.rip
73
+ is_bad, indicator, feed = (False, "", "")
74
+ if matcher:
75
+ is_bad, indicator, feed = matcher.check(ip=c.rip, domain=dest if args.resolve else "")
76
+ # VirusTotal only enriches destinations the free feeds didn't already flag, and only a few per run.
77
+ if vt and not is_bad and vt_budget > 0:
78
+ vt_budget -= 1
79
+ res = vt.check_destination(ip=c.rip, domain=dest if args.resolve else "")
80
+ if res and (res["malicious"] > 0 or res["suspicious"] > 1):
81
+ is_bad, indicator, feed = True, dest, "virustotal"
82
+ store.record_conn(db, c.agent, dest, c.rip, c.rport, s.ts, c.bytes_recv, is_bad, feed)
83
+ if is_bad:
84
+ bad_seen = True
85
+ store.record_alert(db, c.agent, dest, c.rip, feed, indicator, s.ts)
86
+ for a in agents_seen:
87
+ store.record_agent(db, a, s.ts)
88
+ if args.json:
89
+ print(json.dumps({
90
+ "ts": s.ts, "host": s.host, "root": s.is_root,
91
+ "connections": len(conns), "agents": sorted(agents_seen), "bad_seen": bad_seen,
92
+ }))
93
+ else:
94
+ print(f"ephor: {len(conns)} agent connection(s), {len(agents_seen)} agent(s)"
95
+ + (" — \033[31mKNOWN-BAD destination seen\033[0m" if bad_seen else ""))
96
+ db.close()
97
+ return EXIT_BAD if bad_seen else EXIT_OK
98
+
99
+
100
+ def cmd_agents(args) -> int:
101
+ db = store.connect()
102
+ rows = store.agents(db)
103
+ db.close()
104
+ if args.json:
105
+ print(json.dumps(rows, indent=2))
106
+ return EXIT_OK
107
+ if not rows:
108
+ print("no agents recorded yet — run 'ephor watch' (ideally on a timer)")
109
+ return EXIT_OK
110
+ print(f"{'AGENT':<24} {'DESTS':>5} {'DOWNLOADED':>10} {'BAD':>3} {'LAST SEEN':<10}")
111
+ for r in rows:
112
+ flag = f"\033[31m{r['bad_dests']}\033[0m" if r["bad_dests"] else "0"
113
+ print(f"{r['agent']:<24} {r['dests']:>5} {_fmt_bytes(r['bytes_recv']):>10} {flag:>3} "
114
+ f"{_age(r['last_seen']):<10}")
115
+ return EXIT_OK
116
+
117
+
118
+ def cmd_agent(args) -> int:
119
+ db = store.connect()
120
+ rows = store.agent_conns(db, args.name)
121
+ if args.perms:
122
+ s = collect.sample()
123
+ snap = None
124
+ for c in collect.attribute(s):
125
+ if c.agent == args.name:
126
+ snap = perms.snapshot(c.pid, c.user, config.secret_paths())
127
+ break
128
+ db.close()
129
+ if args.json:
130
+ out = {"agent": args.name, "destinations": rows}
131
+ if args.perms:
132
+ out["permissions"] = snap
133
+ print(json.dumps(out, indent=2))
134
+ return EXIT_OK
135
+ if not rows:
136
+ print(f"no destinations recorded for agent '{args.name}'")
137
+ else:
138
+ print(f"== {args.name}: {len(rows)} destination(s) ==")
139
+ print(f"{'DESTINATION':<40} {'PORT':>5} {'DOWNLOADED':>10} {'SEEN':>5} FLAG")
140
+ for r in rows:
141
+ flag = f"\033[31mBAD:{r['feed']}\033[0m" if r["bad"] else ""
142
+ print(f"{r['dest']:<40} {r['port']:>5} {_fmt_bytes(r['bytes_recv']):>10} {r['times_seen']:>5} {flag}")
143
+ if args.perms:
144
+ if not snap:
145
+ print("\npermissions: agent not currently running (snapshot needs a live process)")
146
+ else:
147
+ idn = snap["identity"]
148
+ print(f"\n== {args.name}: permissions (blast radius) ==")
149
+ print(f" runs as : {idn['user']} (uid {idn['uid']}) groups: {', '.join(idn['groups']) or '-'}")
150
+ print(f" sudo nopass : {snap['sudo']}")
151
+ caps = snap["caps"] or ["(none)"]
152
+ hi = set(snap["high_risk_caps"])
153
+ print(" capabilities: " + ", ".join(
154
+ (f"\033[31m{c}\033[0m" if c in hi else c) for c in caps))
155
+ secs = snap["readable_secrets"]
156
+ print(f" can read : {', '.join(secs) if secs else '(no configured secret paths readable)'}")
157
+ return EXIT_OK
158
+
159
+
160
+ def cmd_history(args) -> int:
161
+ db = store.connect()
162
+ rows = store.history(db, agent=args.agent, bad_only=args.bad)
163
+ db.close()
164
+ if args.json:
165
+ print(json.dumps(rows, indent=2))
166
+ return EXIT_OK
167
+ if not rows:
168
+ print("no history")
169
+ return EXIT_OK
170
+ print(f"{'AGENT':<20} {'DESTINATION':<34} {'PORT':>5} {'DOWNLOADED':>10} {'LAST':<9} FLAG")
171
+ for r in rows:
172
+ flag = f"\033[31mBAD:{r['feed']}\033[0m" if r["bad"] else ""
173
+ print(f"{r['agent']:<20} {r['dest']:<34} {r['port']:>5} {_fmt_bytes(r['bytes_recv']):>10} "
174
+ f"{_age(r['last_seen']):<9} {flag}")
175
+ return EXIT_OK
176
+
177
+
178
+ def cmd_status(args) -> int:
179
+ db = store.connect()
180
+ st = store.stats(db)
181
+ db.close()
182
+ m = feeds.Matcher()
183
+ st["feed_indicators"] = m.total()
184
+ st["version"] = __version__
185
+ if args.json:
186
+ print(json.dumps(st, indent=2))
187
+ return EXIT_OK
188
+ print(f"ephor {__version__}")
189
+ print(f" agents tracked : {st['agents']}")
190
+ print(f" destinations : {st['destinations']} ({st['bad_destinations']} known-bad)")
191
+ print(f" alerts : {st['alerts']}")
192
+ print(f" feed indicators : {st['feed_indicators']}")
193
+ vt_on = virustotal.enabled()
194
+ budget = virustotal.VirusTotal().budget if vt_on else None
195
+ print(f" virustotal : {'on' if vt_on else 'off'}"
196
+ + (f" ({budget['left']}/{budget['cap']} left today)" if budget else ""))
197
+ print(f" last sample : {_age(st['last_sample'])}")
198
+ print(f" database : {config.db_path()}")
199
+ return EXIT_OK
200
+
201
+
202
+ def cmd_feeds(args) -> int:
203
+ if args.refresh:
204
+ status = feeds.refresh(force=True)
205
+ else:
206
+ status = feeds.refresh(force=False)
207
+ m = feeds.Matcher()
208
+ if args.json:
209
+ print(json.dumps({"feeds": status, "indicators_loaded": m.total()}, indent=2))
210
+ return EXIT_OK
211
+ for name, (state, count) in status.items():
212
+ print(f" {name:<12} {state:<12} {count} line(s)")
213
+ print(f" loaded indicators: {m.total()}")
214
+ return EXIT_OK
215
+
216
+
217
+ def cmd_query(args) -> int:
218
+ import sqlite3
219
+
220
+ from . import query
221
+ if args.schema:
222
+ print(query.schema())
223
+ return EXIT_OK
224
+ if not args.sql:
225
+ print("ephor: give a SELECT, or --schema to see the tables", file=sys.stderr)
226
+ return 1
227
+ try:
228
+ cols, rows = query.run(args.sql, limit=args.limit)
229
+ except sqlite3.Error as e:
230
+ print(f"ephor: {e}", file=sys.stderr)
231
+ return 1
232
+ if args.json:
233
+ print(json.dumps([dict(zip(cols, r, strict=True)) for r in rows], indent=1, default=str))
234
+ elif cols:
235
+ print(query.table(cols, rows))
236
+ return EXIT_OK
237
+
238
+
239
+ def cmd_installs(args) -> int:
240
+ db = store.connect()
241
+ cmds, pkgs = store.installs(db, args.limit), store.new_packages(db, args.limit)
242
+ db.close()
243
+ if args.json:
244
+ print(json.dumps({"commands": cmds, "new_packages": pkgs}, indent=1))
245
+ return EXIT_OK
246
+ print(f"install commands ({len(cmds)}):")
247
+ for r in cmds:
248
+ flag = f" \033[31m{r['flags']}\033[0m" if r["flags"] else ""
249
+ via = f" via {r['via']}" if r["via"] else ""
250
+ print(f" {_age(r['ts']):<10} {r['agent']}{via:<14} {r['manager']:<8} {r['packages'][:60]}{flag}")
251
+ print(f"\nnew packages since the first snapshot ({len(pkgs)}):")
252
+ for r in pkgs:
253
+ flag = f" \033[31m{r['flags']}\033[0m" if r["flags"] else ""
254
+ print(f" {_age(r['first_seen']):<10} {r['root']:<10} {r['manager']}:{r['name']} {r['version']}"
255
+ f" [{r['agents'] or 'no agent seen'}]{flag}")
256
+ return EXIT_OK
257
+
258
+
259
+ def cmd_dns(args) -> int:
260
+ db = store.connect()
261
+ rows = store.dns_names(db, agent=args.agent, flagged=args.flagged, limit=args.limit)
262
+ db.close()
263
+ if args.json:
264
+ print(json.dumps(rows, indent=1))
265
+ return EXIT_OK
266
+ for r in rows:
267
+ flag = f" \033[31m{r['flags']}\033[0m" if r["flags"] else ""
268
+ print(f"{_age(r['last_seen']):<10} {r['agent']:<20} {r['times_seen']:>5} {r['name']}{flag}")
269
+ return EXIT_OK
270
+
271
+
272
+ def cmd_probe(args) -> int:
273
+ from . import probe
274
+ if args.print:
275
+ print(probe.program(probe.libcs()))
276
+ return EXIT_OK
277
+ why = probe.check()
278
+ if args.check or why:
279
+ if why:
280
+ print("ephor: the eBPF probe can't run here:\n - " + "\n - ".join(why), file=sys.stderr)
281
+ return 1
282
+ libs = probe.libcs()
283
+ light = "" if probe.native() else " (install ephor-probe for ~10 MB instead of ~110 MB)"
284
+ print(f"ephor: eBPF probe ready: backend {probe.backend()}{light}, root, BTF; "
285
+ f"DNS hooks on {len(libs)} libc file(s)")
286
+ return EXIT_OK
287
+ from . import lineage
288
+ st = probe.Stream()
289
+ res = lineage.Resolver()
290
+ print("ephor: probe running; Ctrl-C to stop", file=sys.stderr)
291
+ try:
292
+ while st.alive():
293
+ for ev in st.drain():
294
+ if ev.kind == "exec":
295
+ res.note(ev.pid, ev.ppid, ev.cmd)
296
+ agent, via = res.resolve(ev.pid, ev.cmd if ev.kind == "exec" else "")
297
+ what = ev.cmd if ev.kind == "exec" else ev.name if ev.kind == "dns" else f"{ev.ip}:{ev.port}"
298
+ print(f"{ev.kind:<7} {ev.pid:>7} {agent}{' via ' + via if via else ''} {what[:160]}", flush=True)
299
+ time.sleep(0.5)
300
+ print(f"ephor: probe stopped: {st.error()}", file=sys.stderr)
301
+ return 1
302
+ except KeyboardInterrupt:
303
+ return EXIT_OK
304
+ finally:
305
+ st.stop()
306
+
307
+
308
+ def cmd_dnsproxy(args) -> int:
309
+ from . import dnsproxy, lineage, supply, watch
310
+ matcher = None if args.no_feeds else feeds.Matcher()
311
+ sp = supply.Supply(ebpf=False, inventory_every=0, pkg_checks=False, use_sieve=False, rdap=args.rdap,
312
+ matcher=matcher)
313
+ res = lineage.Resolver()
314
+ sinks = watch.make_sinks(args.webhook or "", args.syslog)
315
+ upstream = args.upstream or dnsproxy.upstream_from_resolv()
316
+ host = os.uname().nodename
317
+
318
+ def on_query(pid: int, name: str) -> None:
319
+ agent, via = res.resolve(pid) if pid else ("unknown", "")
320
+ db = store.connect()
321
+ now = int(time.time())
322
+
323
+ def emit(reason, *, agent, via="", container="", dest="", detail="", **_):
324
+ store.record_alert(db, agent, dest, "", reason, detail[:200], now)
325
+ a = watch.Alert(ts=now, host=host, container=container, agent=agent, client="", crawler=False,
326
+ dest=dest, ip="", port=0, bytes_recv=0, reason=reason, via=via, detail=detail)
327
+ for s in sinks:
328
+ s.send(a)
329
+ with db:
330
+ sp.note_dns(db, now, agent, via, "", name, emit)
331
+ db.close()
332
+ print(f"ephor: dns-proxy on {args.listen} -> {upstream}; point the resolver at it. Ctrl-C to stop",
333
+ file=sys.stderr)
334
+ try:
335
+ dnsproxy.serve(args.listen, upstream, on_query)
336
+ except KeyboardInterrupt:
337
+ return EXIT_OK
338
+ except OSError as e:
339
+ print(f"ephor: dns-proxy: {e}", file=sys.stderr)
340
+ return 1
341
+ return EXIT_OK
342
+
343
+
344
+ def cmd_serve(args) -> int:
345
+ from . import serve as _serve
346
+ return _serve.run(args.host, args.port)
347
+
348
+
349
+ def build_parser() -> argparse.ArgumentParser:
350
+ p = argparse.ArgumentParser(prog="ephor", description="Passive, detect-only monitor of AI-agent network activity.")
351
+ p.add_argument("--version", action="version", version=f"ephor {__version__}")
352
+ sub = p.add_subparsers(dest="cmd", required=True)
353
+
354
+ w = sub.add_parser("watch", help="sample current connections and record them")
355
+ w.add_argument("--resolve", action="store_true", help="reverse-DNS destinations (best-effort)")
356
+ w.add_argument("--no-feeds", action="store_true", help="skip threat-feed matching this run")
357
+ w.add_argument("--virustotal", action="store_true",
358
+ help="also enrich via VirusTotal (sends destinations to a third party; needs a key)")
359
+ w.add_argument("--loop", action="store_true",
360
+ help="run continuously, re-sampling and alerting on new/dangerous activity")
361
+ w.add_argument("--interval", type=int, default=30, metavar="N",
362
+ help="seconds between samples in --loop mode (default 30)")
363
+ w.add_argument("--webhook", help="POST alerts to a Discord/Slack/generic JSON webhook URL")
364
+ w.add_argument("--syslog", action="store_true", help="also emit alerts to syslog")
365
+ w.add_argument("--json", action="store_true")
366
+ w.add_argument("--ebpf", action="store_true",
367
+ help="with --loop: also read the eBPF probe (every command, DNS lookup and connection as it "
368
+ "happens; needs root and bpftrace, run on the host)")
369
+ w.add_argument("--inventory-every", type=int, default=300, metavar="N",
370
+ help="with --loop: snapshot installed packages every N seconds and alert on new ones "
371
+ "(default 300, 0 = off)")
372
+ w.add_argument("--inventory-root", action="append", metavar="PATH",
373
+ help="with --loop: snapshot packages under this root too (repeatable; default /, plus each "
374
+ "container a named agent acts in, when --ebpf is on)")
375
+ w.add_argument("--no-pkg-checks", action="store_true",
376
+ help="don't look new packages up on OSV.dev (malicious) or PyPI/npm (first published)")
377
+ w.add_argument("--sieve", action="store_true",
378
+ help="run `sieve path` where new Python/npm packages land (needs sieve installed)")
379
+ w.add_argument("--rdap", action="store_true",
380
+ help="look up when each new domain an agent resolves was registered (network, RDAP)")
381
+ w.set_defaults(func=cmd_watch)
382
+
383
+ a = sub.add_parser("agents", help="list all agents seen")
384
+ a.add_argument("--json", action="store_true")
385
+ a.set_defaults(func=cmd_agents)
386
+
387
+ g = sub.add_parser("agent", help="show one agent's destinations (and --perms)")
388
+ g.add_argument("name")
389
+ g.add_argument("--perms", action="store_true", help="also show what the agent can access, if live")
390
+ g.add_argument("--json", action="store_true")
391
+ g.set_defaults(func=cmd_agent)
392
+
393
+ h = sub.add_parser("history", help="destination log")
394
+ h.add_argument("--agent", help="filter to one agent")
395
+ h.add_argument("--bad", action="store_true", help="only known-bad destinations")
396
+ h.add_argument("--json", action="store_true")
397
+ h.set_defaults(func=cmd_history)
398
+
399
+ s = sub.add_parser("status", help="database and feed summary")
400
+ s.add_argument("--json", action="store_true")
401
+ s.set_defaults(func=cmd_status)
402
+
403
+ f = sub.add_parser("feeds", help="threat-feed cache state")
404
+ f.add_argument("--refresh", action="store_true", help="force a refetch now")
405
+ f.add_argument("--json", action="store_true")
406
+ f.set_defaults(func=cmd_feeds)
407
+
408
+ q = sub.add_parser("query", help="read-only SQL over the history (osquery-style)")
409
+ q.add_argument("sql", nargs="?", help='e.g. "SELECT agent, name FROM dns WHERE flags != \'\'"')
410
+ q.add_argument("--schema", action="store_true", help="print the tables")
411
+ q.add_argument("--limit", type=int, default=1000, metavar="N")
412
+ q.add_argument("--json", action="store_true")
413
+ q.set_defaults(func=cmd_query)
414
+
415
+ i = sub.add_parser("installs", help="install commands agents ran, and packages that appeared")
416
+ i.add_argument("--limit", type=int, default=50, metavar="N")
417
+ i.add_argument("--json", action="store_true")
418
+ i.set_defaults(func=cmd_installs)
419
+
420
+ d = sub.add_parser("dns", help="names agents looked up (from --ebpf or dns-proxy)")
421
+ d.add_argument("--agent", help="filter to one agent")
422
+ d.add_argument("--flagged", action="store_true", help="only names a check flagged")
423
+ d.add_argument("--limit", type=int, default=200, metavar="N")
424
+ d.add_argument("--json", action="store_true")
425
+ d.set_defaults(func=cmd_dns)
426
+
427
+ pr = sub.add_parser("probe", help="the eBPF probe on its own: --check, --print, or stream events")
428
+ pr.add_argument("--check", action="store_true", help="can the probe run here?")
429
+ pr.add_argument("--print", action="store_true", help="print the bpftrace program it would run")
430
+ pr.set_defaults(func=cmd_probe)
431
+
432
+ dp = sub.add_parser("dns-proxy", help="opt-in local DNS forwarder that ties each name to the process "
433
+ "(for boxes without eBPF)")
434
+ dp.add_argument("--listen", default="127.0.0.1:5353", metavar="ADDR:PORT")
435
+ dp.add_argument("--upstream", metavar="IP", help="real resolver (default: first nameserver in resolv.conf)")
436
+ dp.add_argument("--no-feeds", action="store_true")
437
+ dp.add_argument("--rdap", action="store_true", help="also check how recently each domain was registered")
438
+ dp.add_argument("--webhook", help="POST alerts to a Discord/Slack/generic JSON webhook URL")
439
+ dp.add_argument("--syslog", action="store_true")
440
+ dp.set_defaults(func=cmd_dnsproxy)
441
+
442
+ sv = sub.add_parser("serve", help="read-only HTML view (stdlib; bind to LAN only)")
443
+ sv.add_argument("--host", default="127.0.0.1")
444
+ sv.add_argument("--port", type=int, default=8799)
445
+ sv.set_defaults(func=cmd_serve)
446
+ return p
447
+
448
+
449
+ def main(argv=None) -> int:
450
+ args = build_parser().parse_args(argv)
451
+ return args.func(args)
452
+
453
+
454
+ if __name__ == "__main__":
455
+ sys.exit(main())