openclaw-diag-cli 0.1.0

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,535 @@
1
+ #!/usr/bin/env python3
2
+ """模块 9:插件诊断(一致性 + ERROR/WARN + Hook + Channel + 外部依赖 DNS)。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import glob
7
+ import json
8
+ import os
9
+ import re
10
+ import socket
11
+ import sys
12
+ from collections import Counter, defaultdict
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
17
+
18
+ from ocdiag import cli, output
19
+ from ocdiag.jsonlog import parse_name
20
+ from ocdiag.timeutil import fmt_hms
21
+
22
+
23
+ READY_RE = re.compile(r"ready\s*\((\d+)\s+plugins?:\s*([^;]+);\s*([^)]+)\)")
24
+
25
+ HOOK_HANDLER_FAIL_RE = re.compile(
26
+ r"\[hooks\]\s+(\S+)\s+handler from\s+(\S+)\s+failed:\s+(.*)", re.DOTALL
27
+ )
28
+ HOOK_ASYNC_VIOLATION_RE = re.compile(
29
+ r"(\S+)\s+handler from\s+(\S+)\s+returned a Promise"
30
+ )
31
+ HOOK_AFTER_TOOL_RE = re.compile(
32
+ r"(\w+)\s+hook failed:\s+(?:tool=\S+\s+)?error=(.*)"
33
+ )
34
+ HOOK_INTERNAL_RE = re.compile(
35
+ r"Hook error \[([^\]]+)\]:\s+(.*)"
36
+ )
37
+
38
+ CHANNEL_FAIL_RE = re.compile(
39
+ r"\[([^\]]+)\]\s+channel startup failed:\s+(.*)", re.IGNORECASE
40
+ )
41
+
42
+ _PM_BRACKET_RE = re.compile(r"^\[([^\]]+)\]\s*(.*)", re.DOTALL)
43
+ _PM_EXCLUDED_PREFIXES = frozenset([
44
+ "plugins", "hooks", "ConfigManager", "plugin-manager", "PluginManager",
45
+ ])
46
+ _PM_BODY_PLUGIN_RE = re.compile(r"^([a-z0-9@/_.-]+)\s+failed", re.I)
47
+ _PM_PAREN_PLUGIN_RE = re.compile(
48
+ r"\((?:load|register|init|start|stop):\s*([a-z0-9@/_.-]+)\)", re.I
49
+ )
50
+ _PM_PLUGIN_EQ_RE = re.compile(r"\bplugin=([a-z0-9@/_.-]+)", re.I)
51
+
52
+
53
+ def msg_text(obj):
54
+ v = obj.get("1", "")
55
+ if isinstance(v, str) and v:
56
+ return v
57
+ for k in ("0", "2", "msg", "message"):
58
+ v = obj.get(k, "")
59
+ if isinstance(v, str) and v:
60
+ return v
61
+ return ""
62
+
63
+
64
+ def parse_ts(ts):
65
+ if not ts:
66
+ return None
67
+ try:
68
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
69
+ except Exception:
70
+ return None
71
+
72
+
73
+ def dedup_messages(samples, max_unique=5):
74
+ buckets = {}
75
+ for ts, lvl, text in samples:
76
+ key = text[:60]
77
+ if key not in buckets or (ts or "") > (buckets[key][0] or ""):
78
+ buckets[key] = (ts, lvl, text)
79
+ return sorted(buckets.values(), key=lambda x: x[0] or "", reverse=True)[:max_unique]
80
+
81
+
82
+ def extract_plugin_from_pm_message(text):
83
+ m = _PM_BRACKET_RE.match(text)
84
+ if not m:
85
+ return None
86
+ prefix, body = m.group(1), m.group(2)
87
+ if prefix not in _PM_EXCLUDED_PREFIXES:
88
+ return prefix
89
+ m2 = _PM_BODY_PLUGIN_RE.match(body)
90
+ if m2:
91
+ return m2.group(1)
92
+ m3 = _PM_PAREN_PLUGIN_RE.search(body)
93
+ if m3:
94
+ return m3.group(1)
95
+ m4 = _PM_PLUGIN_EQ_RE.search(text)
96
+ if m4:
97
+ return m4.group(1)
98
+ return None
99
+
100
+
101
+ def scan_logs(today_logs):
102
+ plugin_level_counts = defaultdict(Counter)
103
+ plugin_error_samples = defaultdict(list)
104
+ gateway_starts = []
105
+ hook_errors = []
106
+ subsystem_level_counts = defaultdict(Counter)
107
+ subsystem_error_samples = defaultdict(list)
108
+ plugin_diag_messages = []
109
+
110
+ for logf in today_logs:
111
+ try:
112
+ fh = open(logf, "r", errors="replace")
113
+ except Exception:
114
+ continue
115
+ with fh:
116
+ for line in fh:
117
+ try:
118
+ o = json.loads(line)
119
+ except Exception:
120
+ continue
121
+ plugin, sub = parse_name(o)
122
+ lvl = o.get("_meta", {}).get("logLevelName", "")
123
+ ts = o.get("time") or o.get("_meta", {}).get("date", "")
124
+ text = msg_text(o)
125
+
126
+ if plugin:
127
+ plugin_level_counts[plugin][lvl] += 1
128
+ if lvl in ("WARN", "ERROR", "FATAL"):
129
+ plugin_error_samples[plugin].append((ts, lvl, text))
130
+
131
+ if sub:
132
+ if sub == "gateway":
133
+ m = READY_RE.search(text)
134
+ if m:
135
+ gateway_starts.append((
136
+ parse_ts(ts), ts, m.group(2).strip(), m.group(3).strip(),
137
+ ))
138
+ elif sub == "plugins":
139
+ plugin_diag_messages.append((ts, lvl, text))
140
+ target = extract_plugin_from_pm_message(text)
141
+ if target:
142
+ plugin_level_counts[target][lvl] += 1
143
+ if lvl in ("WARN", "ERROR", "FATAL"):
144
+ plugin_error_samples[target].append((ts, lvl, text))
145
+ elif "/" in sub and not sub.startswith(("gateway", "agent", "skills")):
146
+ subsystem_level_counts[sub][lvl] += 1
147
+ if lvl in ("WARN", "ERROR", "FATAL"):
148
+ subsystem_error_samples[sub].append((ts, lvl, text))
149
+
150
+ m = CHANNEL_FAIL_RE.search(text)
151
+ if m:
152
+ pid = m.group(1)
153
+ plugin_error_samples[pid].append(
154
+ (ts, "ERROR", f"channel startup failed: {m.group(2)}")
155
+ )
156
+ plugin_level_counts[pid]["ERROR"] += 1
157
+
158
+ if sub != "plugins" and not plugin:
159
+ target = extract_plugin_from_pm_message(text)
160
+ if target:
161
+ plugin_level_counts[target][lvl] += 1
162
+ if lvl in ("WARN", "ERROR", "FATAL"):
163
+ plugin_error_samples[target].append((ts, lvl, text))
164
+
165
+ m = HOOK_HANDLER_FAIL_RE.search(text)
166
+ if m:
167
+ hook_errors.append((ts, m.group(2), m.group(1), m.group(3).strip()[:200]))
168
+ continue
169
+ m = HOOK_ASYNC_VIOLATION_RE.search(text)
170
+ if m:
171
+ hook_errors.append((ts, m.group(2), m.group(1), "returned Promise in sync hook"))
172
+ continue
173
+ m = HOOK_AFTER_TOOL_RE.search(text)
174
+ if m:
175
+ hook_errors.append((ts, "(unknown)", m.group(1), m.group(2).strip()[:200]))
176
+ continue
177
+ m = HOOK_INTERNAL_RE.search(text)
178
+ if m:
179
+ hook_errors.append((ts, "(internal)", m.group(1), m.group(2).strip()[:200]))
180
+
181
+ return dict(
182
+ plugin_level_counts=plugin_level_counts,
183
+ plugin_error_samples=plugin_error_samples,
184
+ gateway_starts=gateway_starts,
185
+ hook_errors=hook_errors,
186
+ subsystem_level_counts=subsystem_level_counts,
187
+ subsystem_error_samples=subsystem_error_samples,
188
+ plugin_diag_messages=plugin_diag_messages,
189
+ )
190
+
191
+
192
+ def load_configured(config_path):
193
+ configured = {}
194
+ if config_path and os.path.isfile(config_path):
195
+ try:
196
+ with open(config_path) as f:
197
+ cfg = json.load(f)
198
+ entries = cfg.get("plugins", {}).get("entries", {}) or {}
199
+ for k, v in entries.items():
200
+ if isinstance(v, dict):
201
+ configured[k] = bool(v.get("enabled", False))
202
+ except Exception:
203
+ pass
204
+ return configured
205
+
206
+
207
+ def load_extensions(oc_home):
208
+ extensions = []
209
+ ext_dir = os.path.join(oc_home, "extensions") if oc_home else ""
210
+ if not (ext_dir and os.path.isdir(ext_dir)):
211
+ return extensions
212
+ for d in sorted(os.listdir(ext_dir)):
213
+ full = os.path.join(ext_dir, d)
214
+ if not os.path.isdir(full):
215
+ continue
216
+ pkg = os.path.join(full, "package.json")
217
+ ver = "?"
218
+ if os.path.isfile(pkg):
219
+ try:
220
+ with open(pkg) as f:
221
+ ver = json.load(f).get("version", "?")
222
+ except Exception:
223
+ pass
224
+ extensions.append((d, ver))
225
+ return extensions
226
+
227
+
228
+ def section_state(out, scan, configured, extensions):
229
+ out.subsection("9.1 插件状态一致性")
230
+ gateway_starts = scan["gateway_starts"]
231
+ latest_start = gateway_starts[-1] if gateway_starts else None
232
+ loaded_set = set()
233
+ if latest_start:
234
+ _, _, names_str, dur_str = latest_start
235
+ loaded_list = [n.strip() for n in names_str.split(",")]
236
+ loaded_set = set(loaded_list)
237
+ out.item(f"已加载: {names_str} (启动 {dur_str})")
238
+ else:
239
+ out.item("今日日志中未发现 Gateway ready 行(今日未重启,一致性检查跳过)")
240
+
241
+ config_enabled = set(k for k, v in configured.items() if v)
242
+ config_disabled = set(k for k, v in configured.items() if not v)
243
+
244
+ missing: set = set()
245
+ extra: set = set()
246
+ if loaded_set:
247
+ missing = config_enabled - loaded_set
248
+ extra = loaded_set - config_enabled - config_disabled
249
+ if missing:
250
+ for p in sorted(missing):
251
+ out.item(f"{p}: 配置已启用但未出现在 loaded 列表")
252
+ if extra:
253
+ for p in sorted(extra):
254
+ out.item(f"{p}: 已加载但非显式配置(非显式配置)")
255
+ if config_disabled:
256
+ out.item(f"已禁用: {', '.join(sorted(config_disabled))}")
257
+ if not missing and not extra and not config_disabled:
258
+ out.item("配置与实际加载状态一致")
259
+ else:
260
+ if config_enabled:
261
+ out.item(f"配置已启用: {', '.join(sorted(config_enabled))}")
262
+ if config_disabled:
263
+ out.item(f"配置已禁用: {', '.join(sorted(config_disabled))}")
264
+
265
+ if extensions:
266
+ out.item(f"外部扩展: {', '.join(f'{n} ({v})' for n, v in extensions)}")
267
+
268
+ out.set_data("state", {
269
+ "loaded": sorted(loaded_set),
270
+ "missing": sorted(missing),
271
+ "extra": sorted(extra),
272
+ "disabled": sorted(config_disabled),
273
+ "extensions": [{"name": n, "version": v} for n, v in extensions],
274
+ })
275
+
276
+
277
+ def section_errors(out, scan, configured):
278
+ out.subsection("9.2 插件错误/警告")
279
+ plugin_level_counts = scan["plugin_level_counts"]
280
+ plugin_error_samples = scan["plugin_error_samples"]
281
+ plugin_diag_messages = scan["plugin_diag_messages"]
282
+
283
+ all_plugins = sorted(set(configured.keys()) | set(plugin_level_counts.keys()))
284
+ if not all_plugins and not plugin_diag_messages:
285
+ out.item("(今日无插件日志数据)")
286
+ out.set_data("plugin_errors", {})
287
+ return
288
+
289
+ errors_payload = {}
290
+
291
+ def rank(p):
292
+ c = plugin_level_counts.get(p, Counter())
293
+ return (-(c.get("ERROR", 0) + c.get("FATAL", 0)), -c.get("WARN", 0), p)
294
+
295
+ has_issues = False
296
+ for p in sorted(all_plugins, key=rank):
297
+ c = plugin_level_counts.get(p, Counter())
298
+ err = c.get("ERROR", 0) + c.get("FATAL", 0)
299
+ warn = c.get("WARN", 0)
300
+ total = sum(c.values())
301
+ if err > 0:
302
+ has_issues = True
303
+ elif warn > 20:
304
+ has_issues = True
305
+ note = ""
306
+ if p in configured and not configured[p]:
307
+ note = " [disabled]"
308
+ elif p not in configured and p in plugin_level_counts:
309
+ note = " [auto/extension]"
310
+ out.item(f"{p}: {err} ERROR, {warn} WARN, {total} total{note}")
311
+ samples = plugin_error_samples.get(p, [])
312
+ sample_payload = []
313
+ if samples:
314
+ for ts, lvl, text in dedup_messages(samples, max_unique=999):
315
+ tag = {"ERROR": "E", "FATAL": "F", "WARN": "W"}.get(lvl, "?")
316
+ snippet = text.replace("\n", " ")
317
+ out.item(f" [{tag}] {fmt_hms(ts)}: {snippet}")
318
+ sample_payload.append({"ts": ts, "level": lvl, "msg": text[:300]})
319
+ if err > 0 or warn > 0 or sample_payload:
320
+ errors_payload[p] = {
321
+ "error_count": err,
322
+ "warn_count": warn,
323
+ "total": total,
324
+ "samples": sample_payload,
325
+ }
326
+
327
+ pm_errors = [(ts, lvl, text) for ts, lvl, text in plugin_diag_messages if lvl in ("ERROR", "FATAL")]
328
+ pm_warns = [(ts, lvl, text) for ts, lvl, text in plugin_diag_messages if lvl == "WARN"]
329
+ if pm_errors or pm_warns:
330
+ has_issues = True
331
+ out.item(f"[plugin-manager]: {len(pm_errors)} ERROR, {len(pm_warns)} WARN, "
332
+ f"{len(plugin_diag_messages)} total")
333
+ for ts, _lvl, text in dedup_messages(pm_errors, max_unique=999):
334
+ out.item(f" [E] {fmt_hms(ts)}: {text.replace(chr(10),' ')}")
335
+ for ts, _lvl, text in dedup_messages(pm_warns, max_unique=999):
336
+ out.item(f" [W] {fmt_hms(ts)}: {text.replace(chr(10),' ')}")
337
+ elif plugin_diag_messages:
338
+ out.item(f"[plugin-manager]: 0 ERROR, 0 WARN, {len(plugin_diag_messages)} total")
339
+
340
+ if not has_issues:
341
+ out.item("所有插件 ERROR=0 且 WARN<=20")
342
+
343
+ out.set_data("plugin_errors", errors_payload)
344
+
345
+
346
+ def section_hooks(out, scan):
347
+ out.subsection("9.3 Hook 执行状态")
348
+ hook_errors = scan["hook_errors"]
349
+ if not hook_errors:
350
+ out.item("今日无 Hook 执行异常")
351
+ out.set_data("hook_errors", {"total": 0, "by_plugin": {}})
352
+ return
353
+
354
+ by_plugin = defaultdict(list)
355
+ for ts, plugin_id, hook_name, error_msg in hook_errors:
356
+ by_plugin[plugin_id].append((ts, hook_name, error_msg))
357
+
358
+ out.item(f"共 {len(hook_errors)} 次 Hook 执行异常:")
359
+ out.line("")
360
+ by_plugin_payload = {}
361
+ for plugin_id in sorted(by_plugin, key=lambda p: -len(by_plugin[p])):
362
+ entries = by_plugin[plugin_id]
363
+ by_hook = defaultdict(list)
364
+ for ts, hook_name, err in entries:
365
+ by_hook[hook_name].append((ts, err))
366
+ out.item(f" {plugin_id}: {len(entries)} 次")
367
+ hooks_payload = {}
368
+ for hook_name in sorted(by_hook, key=lambda h: -len(by_hook[h])):
369
+ hook_entries = by_hook[hook_name]
370
+ out.item(f" hook={hook_name}: {len(hook_entries)} 次")
371
+ last = hook_entries[-1]
372
+ out.item(f" 最近: {fmt_hms(last[0])} {last[1][:100]}")
373
+ hooks_payload[hook_name] = {
374
+ "count": len(hook_entries),
375
+ "last_ts": last[0],
376
+ "last_msg": last[1][:300],
377
+ }
378
+ by_plugin_payload[plugin_id] = {
379
+ "count": len(entries),
380
+ "hooks": hooks_payload,
381
+ }
382
+ out.set_data("hook_errors", {
383
+ "total": len(hook_errors),
384
+ "by_plugin": by_plugin_payload,
385
+ })
386
+
387
+
388
+ def section_channels(out, scan):
389
+ out.subsection("9.4 Channel 子系统")
390
+ subsystem_level_counts = scan["subsystem_level_counts"]
391
+ subsystem_error_samples = scan["subsystem_error_samples"]
392
+ if not subsystem_level_counts:
393
+ out.item("(今日无 Channel 子系统日志)")
394
+ out.set_data("channels", {})
395
+ return
396
+
397
+ channel_groups = defaultdict(list)
398
+ for sub in subsystem_level_counts:
399
+ channel_groups[sub.split("/")[0]].append(sub)
400
+
401
+ channels_payload = {}
402
+ for prefix in sorted(channel_groups):
403
+ subs = channel_groups[prefix]
404
+ total_err = sum(
405
+ subsystem_level_counts[s].get("ERROR", 0) + subsystem_level_counts[s].get("FATAL", 0)
406
+ for s in subs
407
+ )
408
+ total_warn = sum(subsystem_level_counts[s].get("WARN", 0) for s in subs)
409
+ total_all = sum(sum(subsystem_level_counts[s].values()) for s in subs)
410
+ out.item(f"{prefix}: {total_err} ERROR, {total_warn} WARN, {total_all} total ({len(subs)} subsystems)")
411
+ if total_err > 0 or total_warn > 20:
412
+ for sub in sorted(subs, key=lambda s: -(subsystem_level_counts[s].get("ERROR", 0))):
413
+ sc = subsystem_level_counts[sub]
414
+ sub_err = sc.get("ERROR", 0) + sc.get("FATAL", 0)
415
+ sub_warn = sc.get("WARN", 0)
416
+ if sub_err > 0 or sub_warn > 10:
417
+ short_sub = sub.split("/", 1)[1] if "/" in sub else sub
418
+ out.item(f" {short_sub}: {sub_err}E {sub_warn}W")
419
+ samples = subsystem_error_samples.get(sub, [])
420
+ if samples:
421
+ for ts, _lvl, text in dedup_messages(samples, max_unique=2):
422
+ out.item(f" [{fmt_hms(ts)}] {text.replace(chr(10),' ')}")
423
+ channels_payload[prefix] = {
424
+ "error_count": total_err,
425
+ "warn_count": total_warn,
426
+ "total": total_all,
427
+ "subsystems": sorted(subs),
428
+ }
429
+ out.set_data("channels", channels_payload)
430
+
431
+
432
+ _URL_IN_VAL_RE = re.compile(r"https?://([A-Za-z0-9][A-Za-z0-9.\-]*)(?::\d+)?")
433
+
434
+
435
+ def walk_urls(val, out_set):
436
+ if isinstance(val, str):
437
+ for m in _URL_IN_VAL_RE.finditer(val):
438
+ out_set.add(m.group(1).lower())
439
+ elif isinstance(val, dict):
440
+ for v in val.values():
441
+ walk_urls(v, out_set)
442
+ elif isinstance(val, list):
443
+ for v in val:
444
+ walk_urls(v, out_set)
445
+
446
+
447
+ def section_deps(out, config_path):
448
+ out.subsection("9.5 插件外部依赖")
449
+ plugin_deps = {}
450
+ if not (config_path and os.path.isfile(config_path)):
451
+ out.item("未发现已启用插件的外部依赖配置")
452
+ out.set_data("plugin_deps", {})
453
+ return
454
+ try:
455
+ with open(config_path) as f:
456
+ cfg_all = json.load(f)
457
+ entries = cfg_all.get("plugins", {}).get("entries", {}) or {}
458
+ for pid, pconf in entries.items():
459
+ if not isinstance(pconf, dict):
460
+ continue
461
+ if not pconf.get("enabled", False):
462
+ continue
463
+ hosts = set()
464
+ walk_urls(pconf, hosts)
465
+ hosts = {h for h in hosts if not h.startswith(("127.", "localhost", "0.0.0.0"))}
466
+ plugin_deps[pid] = hosts
467
+ except Exception:
468
+ pass
469
+
470
+ if not plugin_deps:
471
+ out.item("未发现已启用插件的外部依赖配置")
472
+ out.set_data("plugin_deps", {})
473
+ return
474
+
475
+ out.item(f"扫描: {len(plugin_deps)} 个已启用插件的配置")
476
+ deps_payload: dict = {}
477
+ found_any = False
478
+ no_dep = []
479
+ dep_lines = []
480
+ for pid in sorted(plugin_deps):
481
+ hosts = plugin_deps[pid]
482
+ host_results = []
483
+ if not hosts:
484
+ no_dep.append(pid)
485
+ deps_payload[pid] = {"hosts": []}
486
+ continue
487
+ found_any = True
488
+ for host in sorted(hosts):
489
+ start = datetime.now()
490
+ try:
491
+ socket.setdefaulttimeout(3)
492
+ socket.gethostbyname(host)
493
+ elapsed = (datetime.now() - start).total_seconds() * 1000
494
+ dep_lines.append(f" {pid} → {host}: 可达 ({elapsed:.0f}ms)")
495
+ host_results.append({"host": host, "reachable": True, "elapsed_ms": round(elapsed, 1)})
496
+ except Exception:
497
+ dep_lines.append(f" {pid} → {host}: FAILED (DNS 解析失败)")
498
+ host_results.append({"host": host, "reachable": False, "elapsed_ms": None})
499
+ deps_payload[pid] = {"hosts": host_results}
500
+ if found_any:
501
+ out.item("发现外部端点:")
502
+ for ln in dep_lines:
503
+ out.item(ln)
504
+ if no_dep:
505
+ out.item(f"无外部依赖的插件: {', '.join(no_dep)}")
506
+ out.set_data("plugin_deps", deps_payload)
507
+
508
+
509
+ def main() -> int:
510
+ parser = cli.build_common_parser(
511
+ description="模块 9:插件诊断",
512
+ prog="09_plugin_diag",
513
+ )
514
+ args = parser.parse_args()
515
+ out = output.init("plugin_diag", json_mode=args.json, no_color=args.no_color)
516
+ out.section("模块 9:插件诊断")
517
+
518
+ today = datetime.now().strftime("%Y-%m-%d")
519
+ today_logs = sorted(glob.glob(os.path.join(args.log_dir, f"openclaw-{today}.log")))
520
+
521
+ scan = scan_logs(today_logs)
522
+ configured = load_configured(args.config)
523
+ extensions = load_extensions(args.openclaw_home)
524
+
525
+ section_state(out, scan, configured, extensions)
526
+ section_errors(out, scan, configured)
527
+ section_hooks(out, scan)
528
+ section_channels(out, scan)
529
+ section_deps(out, args.config)
530
+
531
+ return out.done()
532
+
533
+
534
+ if __name__ == "__main__":
535
+ sys.exit(main())
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """模块 10:采集 shell 历史(高危命令、openclaw 命令、最近 20 条)。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import List, Tuple
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
13
+
14
+ from ocdiag import cli, output
15
+
16
+
17
+ DANGEROUS_RE = re.compile(
18
+ r"rm\s+.*-rf|kill\s+.*-9|shutdown|reboot|mkfs|dd\s+if=|>\s*/dev/sd|"
19
+ r"pkill|killall|iptables\s+-F|ufw\s+disable|chmod\s+777",
20
+ re.IGNORECASE,
21
+ )
22
+ SCTL_DANGEROUS_RE = re.compile(r"systemctl\s+(stop|disable)", re.IGNORECASE)
23
+ OC_RE = re.compile(r"openclaw|oc ", re.IGNORECASE)
24
+
25
+
26
+ def list_history_files() -> List[str]:
27
+ home = os.path.expanduser("~")
28
+ candidates = [
29
+ os.path.join(home, ".bash_history"),
30
+ os.path.join(home, ".zsh_history"),
31
+ ]
32
+ if home != "/root":
33
+ candidates.append("/root/.bash_history")
34
+ return [c for c in candidates if os.path.isfile(c)]
35
+
36
+
37
+ def read_lines(path: str) -> List[Tuple[int, str]]:
38
+ out: List[Tuple[int, str]] = []
39
+ try:
40
+ with open(path, "r", errors="replace") as f:
41
+ for i, line in enumerate(f, 1):
42
+ out.append((i, line.rstrip("\n")))
43
+ except OSError:
44
+ pass
45
+ return out
46
+
47
+
48
+ def main() -> int:
49
+ parser = cli.build_common_parser(
50
+ description="模块 10:采集 shell 历史",
51
+ prog="10_shell_history",
52
+ )
53
+ args = parser.parse_args()
54
+
55
+ out = output.init("shell_history", json_mode=args.json, no_color=args.no_color)
56
+ out.section("模块 10:命令执行历史")
57
+ out.line(" 系统 shell 历史记录,用于判断是否有人或脚本执行过高危命令"
58
+ "(rm -rf、kill、systemctl stop 等)。")
59
+ out.line("")
60
+
61
+ history_files = list_history_files()
62
+ if not history_files:
63
+ out.item("未找到 shell 历史文件 (.bash_history / .zsh_history)")
64
+ out.set_data("history_files", [])
65
+ return out.done()
66
+
67
+ files_data = []
68
+ for hfile in history_files:
69
+ lines = read_lines(hfile)
70
+ total = len(lines)
71
+ out.item(f"{os.path.basename(hfile)} — 共 {total} 条记录")
72
+
73
+ dangerous: List[Tuple[int, str]] = []
74
+ for n, ln in lines:
75
+ if DANGEROUS_RE.search(ln) and "openclaw" not in ln.lower():
76
+ dangerous.append((n, ln))
77
+ elif SCTL_DANGEROUS_RE.search(ln) and "openclaw" not in ln.lower():
78
+ dangerous.append((n, ln))
79
+
80
+ if dangerous:
81
+ out.item(f" 高危命令: {len(dangerous)} 条 ")
82
+ ev = "\n".join(f"{n}: {ln}" for n, ln in dangerous)
83
+ out.evidence(f"{hfile} (高危)", ev)
84
+ else:
85
+ out.item(" 高危命令: 0 条")
86
+
87
+ oc_all = [(n, ln) for n, ln in lines if OC_RE.search(ln)]
88
+ oc_total = len(oc_all)
89
+ oc_cmds = oc_all[-30:]
90
+ if oc_total:
91
+ out.item(
92
+ f" ArkClaw 相关命令: 全文 {oc_total} 条,最近 30 条采样 {len(oc_cmds)} 条 — "
93
+ "用户手动执行的 openclaw 命令"
94
+ )
95
+ ev = "\n".join(f"{n}: {ln}" for n, ln in oc_cmds)
96
+ out.evidence(f"{hfile} (openclaw)", ev)
97
+ else:
98
+ out.item(" ArkClaw 相关命令: 0 条")
99
+
100
+ recent = lines[-20:]
101
+ if recent:
102
+ out.item(" 最近 20 条命令:")
103
+ ev = "\n".join(ln for _, ln in recent)
104
+ out.evidence(f"{hfile} (最近)", ev)
105
+
106
+ files_data.append({
107
+ "path": hfile,
108
+ "total_lines": total,
109
+ "dangerous_count": len(dangerous),
110
+ "dangerous": [{"line": n, "cmd": ln} for n, ln in dangerous],
111
+ "openclaw_count_total": oc_total,
112
+ "openclaw_count_sample_30": len(oc_cmds),
113
+ "recent_count": len(recent),
114
+ })
115
+
116
+ out.set_data("history_files", files_data)
117
+ return out.done()
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())
File without changes