agent-lock-cli 0.1.1__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.
agent_lock/doctor.py ADDED
@@ -0,0 +1,1347 @@
1
+ """Classify doctor observations. Live probes are injectable; tests pass fakes.
2
+
3
+ Exit 3 when nothing is installed, before any network probe. Exit 4 beats exit 1
4
+ when local firewall policy or the outbound default makes the lock impossible.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import stat
11
+ import sys
12
+
13
+ from .errors import AgentLockError
14
+ from .darwin import acl_commands
15
+ from .state import CREDENTIAL_DIRS, LINUX_AGENT_HOME, load_state, secret_label
16
+ from .wrapper import CLAUDE_WRAPPER_PATH
17
+
18
+ MIN_CLAUDE = (2, 1, 208)
19
+ PROCESS_WRAPPER_HINT = (2, 1, 210)
20
+
21
+ PROBE_SOURCE = (
22
+ "import socket, sys\n"
23
+ "try:\n"
24
+ ' socket.create_connection(("1.1.1.1", 443), 3)\n'
25
+ "except OSError as e:\n"
26
+ ' sys.stdout.write("agent-lock-probe: denied %s\\n" % (e.errno,))\n'
27
+ " sys.exit(1)\n"
28
+ 'sys.stdout.write("agent-lock-probe: connected\\n")\n'
29
+ "sys.exit(0)\n"
30
+ )
31
+
32
+ NOPROXY_TEXT = (
33
+ "Connection timed out",
34
+ "Connection refused",
35
+ "Network is unreachable",
36
+ "Operation not permitted",
37
+ )
38
+
39
+ _ADMIN_GROUPS = ("sudo", "wheel", "admin", "adm", "Administrators")
40
+
41
+
42
+ def noproxy_locked(code, stderr):
43
+ """True only for curl 28, curl 7, or the listed error text.
44
+
45
+ Exit 0, 126, and 127 are not a lock. 126 is the cmd refusing the owner.
46
+ """
47
+ if code in (28, 7):
48
+ return True
49
+ if code in (0, 126, 127, None):
50
+ return False
51
+ text = stderr or ""
52
+ return any(marker in text for marker in NOPROXY_TEXT)
53
+
54
+
55
+ def probe_denied(stdout, stderr, code):
56
+ """True only when the probe printed denied and did not connect.
57
+
58
+ Exit 126 is the Windows cmd refusing a caller who is not agentnet.
59
+ That refusal did not attempt the connection, so it is not a lock.
60
+ """
61
+ if code in (126, 127, None):
62
+ return False
63
+ text = (stdout or "") + (stderr or "")
64
+ if "ModuleNotFoundError" in text or "agent-lock-probe: connected" in (stdout or ""):
65
+ return False
66
+ return "agent-lock-probe: denied" in (stdout or "")
67
+
68
+
69
+ def probe_connected(stdout, stderr, code):
70
+ if code != 0:
71
+ return False
72
+ text = (stdout or "") + (stderr or "")
73
+ if "ModuleNotFoundError" in text or "agent-lock-probe: denied" in (stdout or ""):
74
+ return False
75
+ return "agent-lock-probe: connected" in (stdout or "")
76
+
77
+
78
+ def parse_claude_version(text):
79
+ match = re.search(r"(\d+)\.(\d+)\.(\d+)", text or "")
80
+ if not match:
81
+ return None
82
+ return tuple(int(part) for part in match.groups())
83
+
84
+
85
+ def version_status(text):
86
+ """Return (level, detail). Missing claude is a skip. Below 2.1.208 fails."""
87
+ if text is None:
88
+ return "skip", "skipped: claude is not on PATH"
89
+ found = parse_claude_version(text)
90
+ if found is None:
91
+ return "fail", "could not parse claude --version"
92
+ if found < MIN_CLAUDE:
93
+ return "fail", "claude {} is older than 2.1.208".format(".".join(str(p) for p in found))
94
+ if found < PROCESS_WRAPPER_HINT:
95
+ return "warn", "warn: the processWrapper key needs Claude Code 2.1.210; agent-lock does not write that key"
96
+ return "pass", "claude {} accepts CLAUDE_CODE_PROCESS_WRAPPER".format(".".join(str(p) for p in found))
97
+
98
+
99
+ def sudo_env_problem(expected, seen):
100
+ """expected and seen map user/logname/home to a string or None when unset."""
101
+ if seen is None:
102
+ return "sudo-env did not run"
103
+ bad = False
104
+ for key in ("user", "logname", "home"):
105
+ if expected.get(key) != seen.get(key):
106
+ bad = True
107
+ value = seen.get(key)
108
+ if value in ("root", "agentnet", "/root", LINUX_AGENT_HOME) and expected.get(key) != value:
109
+ bad = True
110
+ if bad:
111
+ return "sudo rewrote the login name or home directory, not the firewall"
112
+ return None
113
+
114
+
115
+ def bin_dir_problem(entry, agent_gid):
116
+ if entry.get("is_link") or not entry.get("is_dir"):
117
+ return "not a real directory"
118
+ mode = entry.get("mode") or 0
119
+ if not mode & stat.S_ISGID:
120
+ return "missing setgid"
121
+ if entry.get("gid") != agent_gid:
122
+ return "group is not agentnet"
123
+ if not (mode & stat.S_IRGRP and mode & stat.S_IXGRP):
124
+ return "group cannot read and execute"
125
+ if mode & stat.S_IWGRP:
126
+ return "group writable"
127
+ return None
128
+
129
+
130
+ def settings_problem(document, wrapper, proxy):
131
+ if not isinstance(document, dict):
132
+ return "settings are not a JSON object"
133
+ env = document.get("env")
134
+ if not isinstance(env, dict):
135
+ return "settings have no env object"
136
+ if env.get("CLAUDE_CODE_PROCESS_WRAPPER") != wrapper:
137
+ return "CLAUDE_CODE_PROCESS_WRAPPER is not the claude wrapper"
138
+ for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
139
+ if key in env and env[key] != proxy:
140
+ return "{} does not match the locked proxy".format(key)
141
+ if "ALL_PROXY" in env or "all_proxy" in env:
142
+ return "settings set ALL_PROXY"
143
+ process_wrapper = document.get("processWrapper")
144
+ if process_wrapper is not None and process_wrapper != wrapper:
145
+ return "processWrapper does not match the claude wrapper"
146
+ return None
147
+
148
+
149
+ def wrapper_problems(text, port, claude_text, darwin=False):
150
+ problems = []
151
+ if not text:
152
+ return ["switcher is missing"]
153
+ if "wrapper_port={}".format(port) not in text:
154
+ problems.append("switcher port does not match state")
155
+ if "export IFS" in text:
156
+ problems.append("switcher exports IFS")
157
+ printf_at = text.find("IFS=$(/usr/bin/printf ' \\t\\n.')")
158
+ strip_at = text.find("IFS=${IFS%.}")
159
+ if "unset IFS" not in text:
160
+ problems.append("switcher does not unset IFS after the loader loop")
161
+ if "sudo -n -l" not in text:
162
+ problems.append("preflight is not sudo -n -l")
163
+ if "sudo -n -v" in text:
164
+ problems.append("preflight uses the wrong sudo check")
165
+ snap = text.find("AGENT_LOCK_OWNER_USER=${USER-}")
166
+ pre = text.find("/usr/bin/sudo -n -l")
167
+ if snap < 0 or pre < 0 or snap > pre:
168
+ problems.append("owner snapshot is not before the sudo preflight")
169
+ if "unset _np" in text or re.search(r"(?<![A-Za-z0-9])_h(?![A-Za-z0-9])", text):
170
+ problems.append("switcher uses a helper variable")
171
+ unset_at = text.find("unset IFS")
172
+ if darwin:
173
+ marker = "sudo --preserve-env -u"
174
+ else:
175
+ marker = "--preserve-environment -u"
176
+ root_at = text.find(marker)
177
+ if unset_at < 0 or root_at < 0 or root_at < unset_at:
178
+ problems.append("root branch does not drop privileges after stripping loader variables")
179
+ if printf_at < 0 or strip_at < 0 or root_at < 0 or printf_at > root_at or strip_at > root_at:
180
+ problems.append("root branch does not set IFS from printf before exec")
181
+ for line in text.splitlines():
182
+ if marker in line and line.strip().startswith("exec ") and '"$@"' in line and SWITCHER_MARK not in line:
183
+ problems.append("root branch execs the user command")
184
+ if claude_text is None:
185
+ problems.append("claude wrapper is missing")
186
+ else:
187
+ if "unset" in claude_text:
188
+ problems.append("claude wrapper contains unset")
189
+ if "wrapper_port={}".format(port) not in claude_text:
190
+ problems.append("claude wrapper port does not match state")
191
+ return problems
192
+
193
+
194
+ SWITCHER_MARK = "/usr/local/sbin/agent-lock"
195
+
196
+
197
+ def windows_cmd_problems(text, port, sid):
198
+ if not text:
199
+ return ["windows switcher is missing"]
200
+ problems = []
201
+ if "wrapper_port={}".format(port) not in text:
202
+ problems.append("cmd port does not match state")
203
+ if sid and sid not in text:
204
+ problems.append("cmd SID does not match state")
205
+ if "NO_PROXY" in text or "no_proxy" in text:
206
+ problems.append("cmd assigns NO_PROXY")
207
+ for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
208
+ if name not in text:
209
+ problems.append("cmd does not set {}".format(name))
210
+ return problems
211
+
212
+
213
+ def counter_status(before, after, denied_ok):
214
+ """Linux drop counter. A missing denied marker cannot be turned into a pass."""
215
+ if not denied_ok:
216
+ return "fail", "probe did not show denied; the drop counter cannot pass the lock"
217
+ if before is None or after is None:
218
+ return "fail", "drop counter was not readable"
219
+ if after - before < 1:
220
+ return "fail", "drop counter did not increase"
221
+ return "pass", "drop counter increased by {}".format(after - before)
222
+
223
+
224
+ def pf_counter_status(parsed, before, after):
225
+ """Unparsed macOS counters warn. Parsed counters that did not increase fail."""
226
+ if not parsed:
227
+ return "warn", "warn: pf counters could not be parsed"
228
+ if before is None or after is None or after - before < 1:
229
+ return "fail", "pf block counter did not increase"
230
+ return "pass", "pf block counter increased by {}".format(after - before)
231
+
232
+
233
+ def _check(name, level, detail):
234
+ return {
235
+ "name": name,
236
+ "ok": level in ("pass", "warn", "skip"),
237
+ "detail": detail,
238
+ "level": level,
239
+ }
240
+
241
+
242
+ def _level_detail(problem, ok_detail):
243
+ if problem:
244
+ return "fail", problem
245
+ return "pass", ok_detail
246
+
247
+
248
+ def evaluate(report):
249
+ """Turn a facts dict into checks and an exit code. Does not touch the network."""
250
+ if not report.get("installed"):
251
+ detail = report.get("installed_detail") or "state file or switcher is missing"
252
+ return [_check("installed", "fail", detail)], 3
253
+
254
+ platform = report.get("platform") or "linux"
255
+ checks = []
256
+ state = report.get("state") or {}
257
+ port = state.get("port")
258
+ checks.append(_check("installed", "pass", "state, switcher, and account are present"))
259
+
260
+ checks.append(_account_check(report, platform))
261
+ checks.append(_firewall_check(report, platform, port))
262
+ checks.append(_wrapper_check(report, platform, port, state))
263
+ if platform != "windows":
264
+ problem = sudo_env_problem(report.get("expected_identity") or {}, report.get("seen_identity"))
265
+ level, detail = _level_detail(problem, "USER, LOGNAME, and HOME match the owner")
266
+ checks.append(_check("sudo-env", level, detail))
267
+ if platform == "linux":
268
+ checks.append(_bin_dir_check(report, state))
269
+ checks.append(_settings_check(report, state))
270
+ level, detail = version_status(report.get("claude_version"))
271
+ checks.append(_check("claude-wrapper-version", level, detail))
272
+ checks.append(_login_check(report))
273
+ checks.append(_daemon_check(report))
274
+ else:
275
+ warn = report.get("windows_claude_warn") or ""
276
+ if warn:
277
+ checks.append(_check("windows-claude", "warn", "warn: " + warn))
278
+ else:
279
+ checks.append(_check(
280
+ "windows-claude", "pass",
281
+ "no task or service running as the owner was found that would start Claude"))
282
+ checks.append(_controller_check(report, port))
283
+ proxy_level, proxy_detail = _proxy_check(report)
284
+ checks.append(_check("proxy-ok", proxy_level, proxy_detail))
285
+ checks.append(_noproxy_check(report))
286
+ denied_ok = probe_denied(
287
+ report.get("probe_stdout"), report.get("probe_stderr"), report.get("probe_code"))
288
+ if denied_ok:
289
+ checks.append(_check("raw-tcp-fail", "pass", "agent-lock-probe: denied"))
290
+ else:
291
+ checks.append(_check(
292
+ "raw-tcp-fail", "fail",
293
+ "the lock is not in effect; expected agent-lock-probe: denied"))
294
+ if probe_connected(report.get("owner_probe_stdout"), report.get("owner_probe_stderr"),
295
+ report.get("owner_probe_code")):
296
+ checks.append(_check("owner-tcp-ok", "pass", "agent-lock-probe: connected"))
297
+ else:
298
+ checks.append(_check("owner-tcp-ok", "fail", "the owner direct TCP probe did not connect"))
299
+ if platform == "linux":
300
+ level, detail = counter_status(report.get("drop_before"), report.get("drop_after"), denied_ok)
301
+ checks.append(_check("counter", level, detail))
302
+ elif platform == "darwin":
303
+ level, detail = pf_counter_status(
304
+ report.get("pf_counter_parsed"), report.get("pf_before"), report.get("pf_after"))
305
+ checks.append(_check("pf-counter", level, detail))
306
+ checks.append(_unix_check(report))
307
+ if report.get("home_traversable"):
308
+ checks.append(_check("home-traverse", "pass", "agentnet can traverse the owner home"))
309
+ else:
310
+ checks.append(_check(
311
+ "home-traverse", "warn",
312
+ "warn: agentnet cannot traverse the owner home; credential ACLs are not enough"))
313
+ if report.get("exec_access_ok") is True:
314
+ checks.append(_check("exec-access", "pass", report.get("exec_access_detail") or "recorded paths are executable"))
315
+ else:
316
+ checks.append(_check("exec-access", "fail", report.get("exec_access_detail") or "a recorded path is not executable"))
317
+ if platform == "windows":
318
+ gpo = report.get("gpo_problem")
319
+ outbound = report.get("outbound_problem")
320
+ if gpo:
321
+ checks.append(_check("gpo", "unsupported", gpo))
322
+ else:
323
+ checks.append(_check("gpo", "pass", "AllowLocalFirewallRules permits local rules"))
324
+ if outbound:
325
+ checks.append(_check("default-outbound", "unsupported", outbound))
326
+ else:
327
+ checks.append(_check("default-outbound", "pass", "DefaultOutboundAction is not Block"))
328
+ return checks, _exit_code(checks)
329
+
330
+
331
+ def _exit_code(checks):
332
+ levels = [item["level"] for item in checks]
333
+ if "unsupported" in levels:
334
+ return 4
335
+ if "fail" in levels:
336
+ return 1
337
+ return 0
338
+
339
+
340
+ def _account_check(report, platform):
341
+ account = report.get("account") or {}
342
+ if not account.get("exists"):
343
+ return _check("user", "fail", "agentnet does not exist")
344
+ groups = account.get("groups") or []
345
+ for name in _ADMIN_GROUPS:
346
+ if name in groups:
347
+ return _check("user", "fail", "agentnet is in the {} group".format(name))
348
+ if platform == "linux":
349
+ if account.get("shell") != "/usr/sbin/nologin":
350
+ return _check("user", "fail", "agentnet shell is not nologin")
351
+ if not account.get("locked"):
352
+ return _check("user", "fail", "agentnet password is not locked")
353
+ expected_home = account.get("home")
354
+ if expected_home and expected_home != LINUX_AGENT_HOME:
355
+ return _check("user", "fail", "agentnet home is not /var/lib/agentnet")
356
+ if platform == "windows" and not account.get("enabled", True):
357
+ return _check("user", "fail", "agentnet is disabled")
358
+ return _check("user", "pass", "agentnet exists and is not an administrator")
359
+
360
+
361
+ def switcher_mode_problem(uid, mode, platform, owner_name=""):
362
+ """Root (Administrators on Windows) and, on POSIX, not group-writable.
363
+
364
+ CPython reports a writable Windows file as 0o666. That bit is not an ACL,
365
+ so Windows fails only when the owner is not Administrators or the mode
366
+ was not read.
367
+ """
368
+ if mode is None:
369
+ return "switcher mode was not read"
370
+ if platform == "windows":
371
+ if "administrators" not in (owner_name or "").lower():
372
+ return "windows switcher is not owned by Administrators"
373
+ return None
374
+ if mode & stat.S_IWGRP:
375
+ return "switcher is group-writable"
376
+ if uid != 0:
377
+ return "switcher is not owned by root"
378
+ return None
379
+
380
+
381
+ def _firewall_check(report, platform, port):
382
+ if report.get("firewall_unsupported"):
383
+ return _check("firewall", "unsupported", report.get("firewall_detail") or "firewall backend is unsupported")
384
+ if not report.get("firewall_ok"):
385
+ return _check("firewall", "fail", report.get("firewall_detail") or "firewall does not match state")
386
+ extra = report.get("extra_tcp_observed")
387
+ expected = report.get("state", {}).get("extra_tcp") or []
388
+ if extra is None or sorted(extra) != sorted(expected):
389
+ return _check("firewall", "fail", "extra loopback allows do not match state.extra_tcp")
390
+ return _check("firewall", "pass", "firewall matches port {}".format(port))
391
+
392
+
393
+ def _wrapper_check(report, platform, port, state):
394
+ if platform == "windows":
395
+ problems = windows_cmd_problems(report.get("cmd_text"), port, state.get("agent_sid") or "")
396
+ else:
397
+ problems = wrapper_problems(
398
+ report.get("switcher_text"), port, report.get("claude_text"),
399
+ darwin=(platform == "darwin"))
400
+ mode_problem = switcher_mode_problem(
401
+ report.get("switcher_uid"), report.get("switcher_mode"), platform,
402
+ report.get("switcher_owner") or "")
403
+ if mode_problem:
404
+ problems.append(mode_problem)
405
+ if problems:
406
+ return _check("wrapper", "fail", "; ".join(problems))
407
+ return _check("wrapper", "pass", "wrappers match port {}".format(port))
408
+
409
+
410
+ def _bin_dir_check(report, state):
411
+ entries = report.get("bin_dirs")
412
+ if entries is None:
413
+ entries = []
414
+ agent_gid = state.get("agent_gid")
415
+ problems = []
416
+ for entry in entries:
417
+ problem = bin_dir_problem(entry, agent_gid)
418
+ if problem:
419
+ problems.append("{}: {}".format(entry.get("path"), problem))
420
+ if problems:
421
+ return _check("bin-dir", "fail", "; ".join(problems))
422
+ return _check("bin-dir", "pass", "bin directories are setgid and not group-writable" if entries else "no bin directories")
423
+
424
+
425
+ def _settings_check(report, state):
426
+ wrapper = state.get("claude_wrapper") or CLAUDE_WRAPPER_PATH
427
+ proxy = state.get("proxy")
428
+ problems = []
429
+ for item in report.get("settings") or []:
430
+ problem = settings_problem(item.get("document"), wrapper, proxy)
431
+ if problem:
432
+ problems.append("{}: {}".format(item.get("path"), problem))
433
+ if problems:
434
+ return _check("settings", "fail", "; ".join(problems))
435
+ if not (report.get("settings") or state.get("claude_settings")):
436
+ return _check("settings", "skip", "skipped: no claude settings were recorded")
437
+ return _check("settings", "pass", "CLAUDE_CODE_PROCESS_WRAPPER points at the claude wrapper")
438
+
439
+
440
+ def _login_check(report):
441
+ status = report.get("claude_login") or "skip"
442
+ detail = report.get("claude_login_detail") or ""
443
+ if status == "skip":
444
+ return _check("claude-login", "skip", detail or "skipped: Claude is not in use")
445
+ if status == "pass":
446
+ return _check("claude-login", "pass", detail or "agentnet can see a Claude login")
447
+ return _check("claude-login", "fail", detail or "agentnet cannot see a Claude login")
448
+
449
+
450
+ def _daemon_check(report):
451
+ if report.get("daemon_running_as_owner"):
452
+ return _check("claude-daemon", "fail", "claude daemon is running as the owner, not agentnet")
453
+ if report.get("daemon_service_file"):
454
+ return _check(
455
+ "claude-daemon", "warn",
456
+ "warn: the claude daemon service file is still installed; LaunchAgent, deep links, "
457
+ "and the Chrome host stay on the owner until they run inside agentnet")
458
+ return _check("claude-daemon", "pass", "no owner claude daemon is running")
459
+
460
+
461
+ def _controller_check(report, port):
462
+ if report.get("controller_error"):
463
+ return _check("controller", "fail", report["controller_error"])
464
+ if report.get("allow_lan"):
465
+ return _check("controller", "fail", "allow-lan is true")
466
+ if report.get("controller_port") != port:
467
+ return _check("controller", "fail", "controller port does not match state")
468
+ return _check("controller", "pass", "controller mixed-port matches state; {}".format(
469
+ secret_label("x" if report.get("secret_set") else "")))
470
+
471
+
472
+ def _proxy_check(report):
473
+ if report.get("proxy_missing"):
474
+ return "fail", "curl is missing; the proxy check did not run"
475
+ code = report.get("proxy_code")
476
+ if code == 126:
477
+ return "fail", "exit 126 is not a successful proxy check"
478
+ if code == 0:
479
+ return "pass", "proxy curl exited 0"
480
+ stderr = report.get("proxy_stderr") or ""
481
+ if "Connection refused" in stderr or code == 7:
482
+ return "fail", "the proxy is not up"
483
+ return "fail", "proxy curl failed ({})".format(code)
484
+
485
+
486
+ def _noproxy_check(report):
487
+ if report.get("noproxy_missing"):
488
+ return _check("noproxy-fail", "fail", "curl is missing; a missing curl is not a lock")
489
+ code = report.get("noproxy_code")
490
+ stderr = report.get("noproxy_stderr") or ""
491
+ if code == 127:
492
+ return _check("noproxy-fail", "fail", "curl exited 127; the direct connection was not attempted")
493
+ if noproxy_locked(code, stderr):
494
+ return _check("noproxy-fail", "pass", "direct curl was blocked")
495
+ if code == 0:
496
+ return _check("noproxy-fail", "fail", "direct curl exited 0")
497
+ return _check("noproxy-fail", "fail", "direct curl failed without a lock error ({})".format(code))
498
+
499
+
500
+ def _unix_check(report):
501
+ state = report.get("unix_socket")
502
+ if state is None:
503
+ return _check("unix-controller", "skip", "skipped: no unix controller socket")
504
+ if state == "open":
505
+ return _check("unix-controller", "fail", "agentnet can connect to the unix controller")
506
+ return _check("unix-controller", "pass", "agentnet cannot connect to the unix controller")
507
+
508
+
509
+ def public_checks(checks):
510
+ return [{"name": item["name"], "ok": item["ok"], "detail": item["detail"]} for item in checks]
511
+
512
+
513
+ def format_json(checks):
514
+ payload = {"version": 1, "checks": public_checks(checks)}
515
+ return json.dumps(payload, indent=2, sort_keys=True) + "\n"
516
+
517
+
518
+ def format_text(checks):
519
+ lines = []
520
+ for item in checks:
521
+ mark = "ok" if item["ok"] else "FAIL"
522
+ if item["level"] == "warn":
523
+ mark = "warn"
524
+ elif item["level"] == "skip":
525
+ mark = "skip"
526
+ lines.append("{:<24} {} {}".format(item["name"], mark, item["detail"]))
527
+ return "\n".join(lines) + "\n"
528
+
529
+
530
+ def emit(checks, as_json, stdout, stderr):
531
+ if as_json:
532
+ stdout.write(format_json(checks))
533
+ for item in checks:
534
+ if item["level"] == "warn":
535
+ stderr.write(item["detail"] + "\n")
536
+ else:
537
+ stdout.write(format_text(checks))
538
+
539
+
540
+ def repair_plan(state, platform, classify):
541
+ """setfacl or macOS ACL only. Does not chown launchers and does not change the port."""
542
+ roots = list(state.get("share_dirs") or [])
543
+ home = state.get("owner_home") or ""
544
+ for name in CREDENTIAL_DIRS:
545
+ path = os.path.join(home, name)
546
+ if classify(path) != "missing":
547
+ roots.append(path)
548
+ if platform == "linux":
549
+ from .linux import credential_entries as walk
550
+ from .linux import setfacl_commands as commands
551
+ return commands(walk(roots, home, classify))
552
+ if platform == "darwin":
553
+ directories = []
554
+ for root in roots:
555
+ if classify(root) == "dir":
556
+ directories.append(root)
557
+ return acl_commands(directories, home)
558
+ return []
559
+
560
+
561
+ def annotate_probe_messages(checks):
562
+ """When the proxy is down and the lock did not engage, say both."""
563
+ names = {item["name"]: item for item in checks}
564
+ proxy = names.get("proxy-ok")
565
+ raw = names.get("raw-tcp-fail")
566
+ if not proxy or not raw or proxy.get("ok") or raw.get("ok"):
567
+ return checks
568
+ if "the proxy is not up" not in proxy["detail"]:
569
+ proxy["detail"] = proxy["detail"] + "; the proxy is not up"
570
+ if "the lock is not in effect" not in raw["detail"]:
571
+ raw["detail"] = raw["detail"] + "; the lock is not in effect"
572
+ return checks
573
+
574
+
575
+ def run_doctor(args, stdout=None, stderr=None):
576
+ stdout = stdout or sys.stdout
577
+ stderr = stderr or sys.stderr
578
+ try:
579
+ state = load_state()
580
+ except AgentLockError as exc:
581
+ checks = [_check("installed", "fail", str(exc))]
582
+ emit(checks, getattr(args, "json", False), stdout, stderr)
583
+ return 1
584
+ if not state:
585
+ checks = [_check("installed", "fail", "agent-lock is not installed")]
586
+ emit(checks, getattr(args, "json", False), stdout, stderr)
587
+ return 3
588
+ if getattr(args, "repair", False):
589
+ return _repair(state, stdout, stderr)
590
+ report = build_report(state)
591
+ checks, code = evaluate(report)
592
+ annotate_probe_messages(checks)
593
+ emit(checks, getattr(args, "json", False), stdout, stderr)
594
+ return code
595
+
596
+
597
+ def _repair(state, stdout, stderr):
598
+ from .linux import classify_path
599
+ platform = _platform()
600
+ commands = repair_plan(state, platform, classify_path)
601
+ for command in commands:
602
+ if command and command[0] == "chmod" and command[1] == "-a":
603
+ _run(command)
604
+ else:
605
+ result = _run(command)
606
+ if result is not None and result.returncode != 0:
607
+ stderr.write("repair failed: {}\n".format(" ".join(command)))
608
+ return 1
609
+ stdout.write("repaired ACLs; port was not changed\n")
610
+ return 0
611
+
612
+
613
+ def _platform():
614
+ if sys.platform.startswith("linux"):
615
+ return "linux"
616
+ if sys.platform == "darwin":
617
+ return "darwin"
618
+ if sys.platform == "win32":
619
+ return "windows"
620
+ return "other"
621
+
622
+
623
+ def _run(argv, timeout=20):
624
+ import subprocess
625
+ try:
626
+ return subprocess.run(
627
+ argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
628
+ universal_newlines=True, timeout=timeout)
629
+ except (OSError, subprocess.SubprocessError):
630
+ return None
631
+
632
+
633
+ def _read(path):
634
+ if not path:
635
+ return ""
636
+ try:
637
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
638
+ return handle.read()
639
+ except OSError:
640
+ return ""
641
+
642
+
643
+ def build_report(state):
644
+ """Best-effort facts for a live doctor. Missing tools fail the related check."""
645
+ platform = _platform()
646
+ report = {
647
+ "installed": bool(state) and bool(_switcher_present(state, platform)),
648
+ "platform": platform,
649
+ "state": state,
650
+ "secret_set": bool(state.get("secret")),
651
+ "switcher_text": _read(state.get("wrapper")),
652
+ "claude_text": _read(state.get("claude_wrapper")) if platform != "windows" else None,
653
+ "cmd_text": _read(state.get("wrapper")) if platform == "windows" else "",
654
+ "account": _account_facts(state, platform),
655
+ "firewall_ok": False,
656
+ "firewall_detail": "firewall was not queried",
657
+ "expected_identity": {
658
+ "user": state.get("owner") or None,
659
+ "logname": state.get("owner") or None,
660
+ "home": state.get("owner_home") or None,
661
+ },
662
+ "seen_identity": None,
663
+ "bin_dirs": _bin_dir_facts(state) if platform == "linux" else [],
664
+ "settings": _settings_facts(state) if platform != "windows" else [],
665
+ "claude_version": _claude_version() if platform != "windows" else None,
666
+ "claude_login": "skip",
667
+ "claude_login_detail": "skipped: Claude login was not probed",
668
+ "daemon_running_as_owner": False,
669
+ "daemon_service_file": False,
670
+ "windows_claude_warn": "",
671
+ "controller_error": "controller was not re-read",
672
+ "controller_port": None,
673
+ "allow_lan": False,
674
+ "proxy_missing": _which("curl") is None,
675
+ "proxy_code": None,
676
+ "proxy_stderr": "",
677
+ "noproxy_missing": _which("curl") is None,
678
+ "noproxy_code": None,
679
+ "noproxy_stderr": "",
680
+ "probe_stdout": "",
681
+ "probe_stderr": "",
682
+ "probe_code": 127,
683
+ "owner_probe_stdout": "",
684
+ "owner_probe_stderr": "",
685
+ "owner_probe_code": 127,
686
+ "drop_before": None,
687
+ "drop_after": None,
688
+ "pf_counter_parsed": False,
689
+ "pf_before": None,
690
+ "pf_after": None,
691
+ "unix_socket": None,
692
+ "home_traversable": False,
693
+ "exec_access_ok": False,
694
+ "exec_access_detail": "",
695
+ "extra_tcp_observed": None,
696
+ "gpo_problem": None,
697
+ "outbound_problem": None,
698
+ "switcher_uid": None,
699
+ "switcher_mode": None,
700
+ "switcher_owner": "",
701
+ }
702
+ uid, mode = _switcher_stat(state.get("wrapper"))
703
+ report["switcher_uid"] = uid
704
+ report["switcher_mode"] = mode
705
+ if not report["installed"]:
706
+ report["installed_detail"] = "switcher or account is missing"
707
+ return report
708
+ _fill_network(report, state, platform)
709
+ _fill_claude(report, state, platform)
710
+ return report
711
+
712
+
713
+ def _switcher_stat(path):
714
+ if not path:
715
+ return None, None
716
+ try:
717
+ st = os.lstat(path)
718
+ except OSError:
719
+ return None, None
720
+ return st.st_uid, stat.S_IMODE(st.st_mode)
721
+
722
+
723
+ def _switcher_present(state, platform):
724
+ path = state.get("wrapper") or ""
725
+ if platform != "windows" and not os.path.isfile(path):
726
+ return False
727
+ if platform == "windows" and path and not os.path.isfile(path):
728
+ return False
729
+ return bool(state.get("agent_user"))
730
+
731
+
732
+ def _which(name):
733
+ import shutil
734
+ return shutil.which(name)
735
+
736
+
737
+ def _account_facts(state, platform):
738
+ facts = {"exists": False, "groups": [], "shell": "", "locked": False, "enabled": True, "home": ""}
739
+ if platform == "linux":
740
+ result = _run(["getent", "passwd", "agentnet"])
741
+ if result and result.returncode == 0 and result.stdout.strip():
742
+ parts = result.stdout.strip().split(":")
743
+ facts["exists"] = True
744
+ if len(parts) > 6:
745
+ facts["home"] = parts[5]
746
+ facts["shell"] = parts[6]
747
+ groups = _run(["id", "-nG", "agentnet"])
748
+ if groups and groups.returncode == 0:
749
+ facts["groups"] = groups.stdout.split()
750
+ status = _run(["passwd", "-S", "agentnet"])
751
+ if status and status.returncode == 0:
752
+ # passwd -S prints "agentnet L ..." when locked.
753
+ fields = status.stdout.split()
754
+ facts["locked"] = len(fields) > 1 and fields[1].startswith("L")
755
+ elif platform == "darwin":
756
+ result = _run(["id", "agentnet"])
757
+ facts["exists"] = bool(result and result.returncode == 0)
758
+ groups = _run(["id", "-Gn", "agentnet"])
759
+ if groups and groups.returncode == 0:
760
+ facts["groups"] = groups.stdout.split()
761
+ elif platform == "windows":
762
+ from .windows import windows_admin_script, windows_user_script
763
+ result = _run(["powershell", "-NoProfile", "-Command", windows_user_script()])
764
+ raw = (result.stdout or "").strip() if result and result.returncode == 0 else ""
765
+ data = None
766
+ if raw:
767
+ try:
768
+ data = json.loads(raw)
769
+ except ValueError:
770
+ data = None
771
+ if isinstance(data, list):
772
+ data = data[0] if data else None
773
+ if isinstance(data, dict) and data.get("Name"):
774
+ facts["exists"] = True
775
+ enabled = data.get("Enabled")
776
+ facts["enabled"] = enabled is True or str(enabled).lower() == "true"
777
+ admin = _run(["powershell", "-NoProfile", "-Command", windows_admin_script()])
778
+ if admin and "yes" in (admin.stdout or "").lower():
779
+ facts["groups"] = ["Administrators"]
780
+ return facts
781
+
782
+
783
+ def _bin_dir_facts(state):
784
+ facts = []
785
+ for path in state.get("bin_dirs") or []:
786
+ try:
787
+ st = os.lstat(path)
788
+ except OSError:
789
+ facts.append({"path": path, "is_dir": False, "is_link": False, "mode": 0, "gid": 0})
790
+ continue
791
+ facts.append({
792
+ "path": path,
793
+ "is_dir": stat.S_ISDIR(st.st_mode),
794
+ "is_link": stat.S_ISLNK(st.st_mode),
795
+ "mode": stat.S_IMODE(st.st_mode),
796
+ "gid": st.st_gid,
797
+ })
798
+ return facts
799
+
800
+
801
+ def _settings_facts(state):
802
+ items = []
803
+ for entry in state.get("claude_settings") or []:
804
+ path = entry.get("path") if isinstance(entry, dict) else None
805
+ document = {}
806
+ if path and os.path.isfile(path):
807
+ try:
808
+ with open(path, "r", encoding="utf-8") as handle:
809
+ document = json.load(handle)
810
+ except (OSError, ValueError):
811
+ document = {}
812
+ items.append({"path": path, "document": document})
813
+ return items
814
+
815
+
816
+ def _claude_version():
817
+ if _which("claude") is None:
818
+ return None
819
+ result = _run(["claude", "--version"], timeout=15)
820
+ if not result:
821
+ return ""
822
+ return (result.stdout or "") + (result.stderr or "")
823
+
824
+
825
+ def _fill_windows_probes(report, state):
826
+ """Owner probe stays this process. Locked probe and curls run as agentnet.
827
+
828
+ agent-lock.cmd is not a switch: it exits 126 for the installing user.
829
+ The locked probe is python reading stdin, not a file in this user's temp.
830
+ """
831
+ from .windows import parse_locked_batch, windows_locked_command
832
+ python = sys.executable or "python3"
833
+ proxy = ["curl", "-sS", "-o", os.devnull, "--max-time", "5",
834
+ "-x", state.get("proxy") or "", "https://example.com"]
835
+ direct = ["curl", "-sS", "-o", os.devnull, "--max-time", "3",
836
+ "--noproxy", "*", "https://1.1.1.1/"]
837
+ try:
838
+ command = windows_locked_command(
839
+ state.get("agent_sid") or "", python, PROBE_SOURCE, proxy, direct)
840
+ except AgentLockError:
841
+ command = None
842
+ result = _run(command, timeout=60) if command else None
843
+ if not result or result.returncode != 0:
844
+ code = 126 if result and result.returncode == 126 else 127
845
+ report["probe_code"] = code
846
+ report["probe_stderr"] = "locked probe was not run as agentnet"
847
+ report["proxy_code"] = code
848
+ report["noproxy_code"] = code
849
+ else:
850
+ parsed = parse_locked_batch(result.stdout or "")
851
+ report["probe_code"] = parsed["probe_code"] if parsed["probe_code"] is not None else 127
852
+ report["probe_stdout"] = parsed["probe_stdout"]
853
+ report["probe_stderr"] = parsed["probe_stderr"]
854
+ report["proxy_code"] = parsed["proxy_code"]
855
+ report["proxy_stderr"] = parsed["proxy_stderr"]
856
+ report["noproxy_code"] = parsed["noproxy_code"]
857
+ report["noproxy_stderr"] = parsed["noproxy_stderr"]
858
+ owner = _run([python, "-c", PROBE_SOURCE], timeout=10)
859
+ if owner:
860
+ report["owner_probe_code"] = owner.returncode
861
+ report["owner_probe_stdout"] = owner.stdout
862
+ report["owner_probe_stderr"] = owner.stderr
863
+
864
+
865
+ def _fill_network(report, state, platform):
866
+ """Run the curl and TCP probes. Firewall queries use sudo and may fail closed."""
867
+ python = sys.executable or "python3"
868
+ switcher = state.get("wrapper") or ""
869
+ probe = [python, "-c", PROBE_SOURCE]
870
+ if platform == "linux":
871
+ report["drop_before"] = _linux_drop_packets()
872
+ elif platform == "darwin":
873
+ parsed, count = _pf_block_count()
874
+ report["pf_counter_parsed"] = parsed
875
+ report["pf_before"] = count
876
+ if platform == "windows":
877
+ _fill_windows_probes(report, state)
878
+ else:
879
+ if switcher and os.path.isfile(switcher):
880
+ locked = _run([switcher] + probe, timeout=15)
881
+ if locked:
882
+ report["probe_code"] = locked.returncode
883
+ report["probe_stdout"] = locked.stdout
884
+ report["probe_stderr"] = locked.stderr
885
+ if not report["proxy_missing"]:
886
+ proxy = _run([switcher, "curl", "-sS", "-o", os.devnull, "--max-time", "5",
887
+ "-x", state.get("proxy") or "", "https://example.com"], timeout=15)
888
+ if proxy:
889
+ report["proxy_code"] = proxy.returncode
890
+ report["proxy_stderr"] = proxy.stderr
891
+ direct = _run([switcher, "curl", "-sS", "-o", os.devnull, "--max-time", "3",
892
+ "--noproxy", "*", "https://1.1.1.1/"], timeout=15)
893
+ if direct:
894
+ report["noproxy_code"] = direct.returncode
895
+ report["noproxy_stderr"] = direct.stderr
896
+ owner = _run(probe, timeout=10)
897
+ if owner:
898
+ report["owner_probe_code"] = owner.returncode
899
+ report["owner_probe_stdout"] = owner.stdout
900
+ report["owner_probe_stderr"] = owner.stderr
901
+ if platform == "linux":
902
+ report["drop_after"] = _linux_drop_packets()
903
+ elif platform == "darwin":
904
+ parsed, count = _pf_block_count()
905
+ if not parsed:
906
+ report["pf_counter_parsed"] = False
907
+ report["pf_after"] = count
908
+ report["firewall_ok"] = False
909
+ report["firewall_detail"] = "firewall was not queried"
910
+ if platform == "linux":
911
+ _fill_linux_firewall(report, state)
912
+ elif platform == "darwin":
913
+ _fill_darwin_firewall(report, state)
914
+ elif platform == "windows":
915
+ _fill_windows_firewall(report, state)
916
+ _fill_controller(report, state)
917
+ _fill_identity(report, state, platform)
918
+ _fill_home(report, state, platform)
919
+ _fill_unix(report, state, platform)
920
+ return report
921
+
922
+
923
+ def _fill_linux_firewall(report, state):
924
+ from .linux import nft_observed
925
+ nft = _which("nft")
926
+ if not nft:
927
+ report["firewall_unsupported"] = True
928
+ report["firewall_detail"] = "nft is not installed"
929
+ report["extra_tcp_observed"] = None
930
+ return
931
+ result = _run(["sudo", nft, "-j", "list", "table", "inet", "agentlock"], timeout=20)
932
+ if not result or result.returncode != 0:
933
+ report["firewall_ok"] = False
934
+ report["firewall_detail"] = "could not list table inet agentlock"
935
+ report["extra_tcp_observed"] = None
936
+ return
937
+ try:
938
+ data = json.loads(result.stdout)
939
+ except ValueError:
940
+ report["firewall_detail"] = "nft JSON was not readable"
941
+ report["extra_tcp_observed"] = None
942
+ report["firewall_ok"] = False
943
+ return
944
+ ok, extras = nft_observed(data, state.get("port") or 0)
945
+ report["extra_tcp_observed"] = extras
946
+ report["firewall_ok"] = bool(ok)
947
+ if ok:
948
+ report["firewall_detail"] = "nft table matches the saved port"
949
+ else:
950
+ report["firewall_detail"] = "nft table does not match the saved port"
951
+
952
+
953
+ def _fill_darwin_firewall(report, state):
954
+ from .darwin import anchor_observed, pf_conf_problem
955
+ text = _read("/etc/pf.conf")
956
+ problem = pf_conf_problem(text)
957
+ if problem:
958
+ report["firewall_ok"] = False
959
+ report["firewall_detail"] = problem
960
+ report["extra_tcp_observed"] = None
961
+ return
962
+ result = _run(["sudo", "pfctl", "-a", "agentlock", "-sr"], timeout=20)
963
+ if not result or result.returncode != 0:
964
+ report["firewall_ok"] = False
965
+ report["firewall_detail"] = "could not read the agentlock anchor"
966
+ report["extra_tcp_observed"] = None
967
+ return
968
+ ok, extras = anchor_observed(result.stdout or "", state.get("port") or 0)
969
+ report["extra_tcp_observed"] = extras
970
+ report["firewall_ok"] = bool(ok)
971
+ if ok:
972
+ report["firewall_detail"] = "pf anchor passes the state port and blocks other traffic"
973
+ else:
974
+ report["firewall_detail"] = "pf anchor does not pass the state port and block other traffic"
975
+
976
+
977
+ def _fill_windows_firewall(report, state):
978
+ from .windows import windows_firewall_report, windows_profile_script, windows_rule_script
979
+ profile = _run(["powershell", "-NoProfile", "-Command", windows_profile_script()], timeout=30)
980
+ if not profile or profile.returncode != 0 or not (profile.stdout or "").strip():
981
+ report["firewall_ok"] = False
982
+ report["firewall_detail"] = "could not query Windows firewall profiles"
983
+ report["extra_tcp_observed"] = None
984
+ return
985
+ sid = state.get("agent_sid") or ""
986
+ try:
987
+ rule_script = windows_rule_script(sid)
988
+ except AgentLockError as exc:
989
+ report["firewall_ok"] = False
990
+ report["firewall_detail"] = str(exc)
991
+ report["extra_tcp_observed"] = None
992
+ return
993
+ rules = _run(["powershell", "-NoProfile", "-Command", rule_script], timeout=30)
994
+ if not rules or rules.returncode != 0:
995
+ report["firewall_ok"] = False
996
+ report["firewall_detail"] = "could not query Windows firewall rules"
997
+ report["extra_tcp_observed"] = None
998
+ return
999
+ try:
1000
+ profiles = json.loads(profile.stdout)
1001
+ parsed_rules = json.loads(rules.stdout) if (rules.stdout or "").strip() else []
1002
+ except ValueError:
1003
+ report["firewall_ok"] = False
1004
+ report["firewall_detail"] = "Windows firewall query was not JSON"
1005
+ report["extra_tcp_observed"] = None
1006
+ return
1007
+ if isinstance(profiles, dict):
1008
+ profiles = [profiles]
1009
+ if isinstance(parsed_rules, dict):
1010
+ parsed_rules = [parsed_rules]
1011
+ gpo, outbound, ok, extras = windows_firewall_report(profiles, parsed_rules, state.get("port") or 0)
1012
+ report["gpo_problem"] = gpo
1013
+ report["outbound_problem"] = outbound
1014
+ report["firewall_ok"] = bool(ok)
1015
+ report["extra_tcp_observed"] = extras
1016
+ if gpo or outbound:
1017
+ report["firewall_detail"] = gpo or outbound
1018
+ elif ok:
1019
+ report["firewall_detail"] = "Windows firewall blocks this SID except the proxy port"
1020
+ else:
1021
+ report["firewall_detail"] = "Windows firewall does not match the saved port"
1022
+ owner = _run(["powershell", "-NoProfile", "-Command",
1023
+ "(Get-Acl -LiteralPath '{}').Owner".format(state.get("wrapper") or "")], timeout=20)
1024
+ if owner and owner.returncode == 0:
1025
+ report["switcher_owner"] = (owner.stdout or "").strip()
1026
+
1027
+
1028
+ def _linux_drop_packets():
1029
+ nft = _which("nft")
1030
+ if not nft:
1031
+ return None
1032
+ result = _run(["sudo", nft, "-j", "list", "counter", "inet", "agentlock", "agentlock_drop"], timeout=15)
1033
+ if not result or result.returncode != 0:
1034
+ return None
1035
+ try:
1036
+ data = json.loads(result.stdout or "")
1037
+ except ValueError:
1038
+ return None
1039
+ items = data.get("nftables") if isinstance(data, dict) else None
1040
+ if not isinstance(items, list):
1041
+ return None
1042
+ for item in items:
1043
+ counter = item.get("counter") if isinstance(item, dict) else None
1044
+ if isinstance(counter, dict) and counter.get("name") == "agentlock_drop":
1045
+ packets = counter.get("packets")
1046
+ if isinstance(packets, int):
1047
+ return packets
1048
+ return None
1049
+
1050
+
1051
+ def _pf_block_count():
1052
+ """(parsed, packets). Unparsed stays a warning in pf_counter_status."""
1053
+ result = _run(["sudo", "pfctl", "-a", "agentlock", "-sr", "-v"], timeout=15)
1054
+ if not result or result.returncode != 0 or not result.stdout:
1055
+ return False, None
1056
+ packets = None
1057
+ pending = False
1058
+ for line in result.stdout.splitlines():
1059
+ stripped = line.strip()
1060
+ if stripped.startswith("block "):
1061
+ pending = True
1062
+ continue
1063
+ if pending and "Packets:" in stripped:
1064
+ for token in stripped.replace("Packets:", " ").split():
1065
+ if token.isdigit():
1066
+ packets = int(token)
1067
+ pending = False
1068
+ break
1069
+ if packets is None:
1070
+ return False, None
1071
+ return True, packets
1072
+
1073
+
1074
+ def _fill_controller(report, state):
1075
+ address = state.get("controller") or ""
1076
+ if not address:
1077
+ report["controller_error"] = "no controller address is stored"
1078
+ report["controller_port"] = None
1079
+ return
1080
+ from .discover import Candidate, default_fetch, discover
1081
+ kind = "unix" if str(address).startswith("unix:") else "tcp"
1082
+ secret = state.get("secret") or ""
1083
+ try:
1084
+ found = discover([Candidate(address, secret, "state", kind)], default_fetch, None)
1085
+ except Exception as exc:
1086
+ # discover redacts the secret; do not attach the raw exception if it still holds it.
1087
+ text = str(exc)
1088
+ if secret and secret in text:
1089
+ text = text.replace(secret, secret_label(secret))
1090
+ report["controller_error"] = text or "controller was not readable"
1091
+ return
1092
+ report["controller_error"] = None
1093
+ report["controller_port"] = found.get("port")
1094
+ report["allow_lan"] = False
1095
+
1096
+
1097
+ def identity_commands(switcher):
1098
+ """One printenv per name so an unset variable stays unset instead of shifting lines."""
1099
+ return [[switcher, "/usr/bin/printenv", name] for name in ("USER", "LOGNAME", "HOME")]
1100
+
1101
+
1102
+ def identity_expected(environ):
1103
+ found = {}
1104
+ for key, name in (("user", "USER"), ("logname", "LOGNAME"), ("home", "HOME")):
1105
+ found[key] = environ[name] if name in environ else None
1106
+ return found
1107
+
1108
+
1109
+ def identity_from_results(results):
1110
+ """(returncode, stdout) per name. A non-zero status is an unset variable."""
1111
+ if not results or any(item is None for item in results):
1112
+ return None
1113
+ seen = {}
1114
+ for key, item in zip(("user", "logname", "home"), results):
1115
+ code, stdout = item
1116
+ if code != 0:
1117
+ seen[key] = None
1118
+ else:
1119
+ lines = (stdout or "").splitlines()
1120
+ seen[key] = lines[0] if lines else None
1121
+ return seen
1122
+
1123
+
1124
+ def _fill_identity(report, state, platform):
1125
+ if platform == "windows":
1126
+ return
1127
+ report["expected_identity"] = identity_expected(os.environ)
1128
+ _run(["sudo", "-k"])
1129
+ switcher = state.get("wrapper") or ""
1130
+ if not switcher or not os.path.isfile(switcher):
1131
+ report["seen_identity"] = None
1132
+ return
1133
+ results = []
1134
+ for command in identity_commands(switcher):
1135
+ result = _run(command)
1136
+ if result is None:
1137
+ results.append(None)
1138
+ else:
1139
+ results.append((result.returncode, result.stdout))
1140
+ report["seen_identity"] = identity_from_results(results)
1141
+
1142
+
1143
+ def _fill_home(report, state, platform):
1144
+ home = state.get("owner_home") or ""
1145
+ switcher = state.get("wrapper") or ""
1146
+ if not home or platform == "windows" or not switcher:
1147
+ report["home_traversable"] = False
1148
+ return
1149
+ result = _run([switcher, "test", "-x", home])
1150
+ report["home_traversable"] = bool(result and result.returncode == 0)
1151
+
1152
+
1153
+ def _fill_unix(report, state, platform):
1154
+ if platform == "windows":
1155
+ return
1156
+ paths = []
1157
+ controller = state.get("controller") or ""
1158
+ if str(controller).startswith("unix:"):
1159
+ paths.append(controller[len("unix:"):])
1160
+ home = state.get("owner_home") or ""
1161
+ if home:
1162
+ paths.append(os.path.join(home, ".local", "share", "mihomo-cli", "mihomo.sock"))
1163
+ existing = [path for path in paths if path and os.path.exists(path)]
1164
+ if not existing:
1165
+ report["unix_socket"] = None
1166
+ return
1167
+ # Fail closed when the socket is there and we cannot prove agentnet is blocked.
1168
+ report["unix_socket"] = "open"
1169
+ code = (
1170
+ "import socket, sys\n"
1171
+ "s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\n"
1172
+ "s.settimeout(3)\n"
1173
+ "try:\n"
1174
+ " s.connect(sys.argv[1])\n"
1175
+ "except OSError:\n"
1176
+ " sys.exit(1)\n"
1177
+ "sys.exit(0)\n"
1178
+ )
1179
+ python = sys.executable or "python3"
1180
+ switcher = state.get("wrapper") or ""
1181
+ for path in existing:
1182
+ if not switcher:
1183
+ return
1184
+ result = _run([switcher, python, "-c", code, path])
1185
+ if result is None:
1186
+ return
1187
+ if result.returncode == 0:
1188
+ report["unix_socket"] = "open"
1189
+ return
1190
+ report["unix_socket"] = "closed"
1191
+
1192
+
1193
+ def classify_login(probe):
1194
+ """probe keys: attempted, keychain_account, credentials_account, owner.
1195
+
1196
+ keychain_account is only set when the agentnet probe succeeded. The owner's
1197
+ own keychain does not count.
1198
+ """
1199
+ if not probe or not probe.get("attempted"):
1200
+ return "skip", "skipped: Claude login was not probed"
1201
+ owner = probe.get("owner") or ""
1202
+ if owner and probe.get("keychain_account") == owner:
1203
+ return "pass", "agentnet keychain has the owner account"
1204
+ if owner and probe.get("credentials_account") == owner:
1205
+ return "pass", "agentnet can read a Claude credential for the owner"
1206
+ return "fail", "agentnet cannot see a Claude login"
1207
+
1208
+
1209
+ def credentials_account(text, owner):
1210
+ if not text or not owner:
1211
+ return None
1212
+ try:
1213
+ data = json.loads(text)
1214
+ except ValueError:
1215
+ return None
1216
+ found = []
1217
+
1218
+ def walk(node):
1219
+ if isinstance(node, dict):
1220
+ for key, value in node.items():
1221
+ if key.lower() in ("account", "user", "email", "username") and isinstance(value, str):
1222
+ found.append(value)
1223
+ else:
1224
+ walk(value)
1225
+ elif isinstance(node, list):
1226
+ for item in node:
1227
+ walk(item)
1228
+
1229
+ walk(data)
1230
+ if owner in found:
1231
+ return owner
1232
+ return None
1233
+
1234
+
1235
+ def classify_daemon(rows, agent_uid, service_file):
1236
+ """Fail when a claude daemon is running as someone other than agentnet.
1237
+
1238
+ A service file warns only when no daemon process is running.
1239
+ """
1240
+ owner_running = False
1241
+ agent_running = False
1242
+ for uid, command in rows or []:
1243
+ text = command or ""
1244
+ if "claude-daemon" not in text and "com.anthropic.claude-daemon" not in text:
1245
+ continue
1246
+ if agent_uid and int(uid) == int(agent_uid):
1247
+ agent_running = True
1248
+ else:
1249
+ owner_running = True
1250
+ if owner_running:
1251
+ return True, False
1252
+ if service_file and not agent_running:
1253
+ return False, True
1254
+ return False, False
1255
+
1256
+
1257
+ def parse_ps_uid_command(text):
1258
+ rows = []
1259
+ for line in (text or "").splitlines():
1260
+ line = line.strip()
1261
+ if not line:
1262
+ continue
1263
+ uid, _sep, command = line.partition(" ")
1264
+ if uid.isdigit():
1265
+ rows.append((int(uid), command.strip()))
1266
+ return rows
1267
+
1268
+
1269
+ def exec_access_problem(codes):
1270
+ if any(code != 0 for code in codes):
1271
+ return "a recorded path is not executable"
1272
+ return None
1273
+
1274
+
1275
+ def daemon_service_paths(home):
1276
+ home = home or ""
1277
+ return [
1278
+ os.path.join(home, "Library", "LaunchAgents", "com.anthropic.claude-daemon.plist"),
1279
+ os.path.join(home, ".config", "systemd", "user", "claude-daemon.service"),
1280
+ ]
1281
+
1282
+
1283
+ def _fill_claude(report, state, platform):
1284
+ if platform == "windows":
1285
+ from .windows import windows_path_executable
1286
+ paths = list(state.get("exec_paths") or [])
1287
+ if not paths:
1288
+ report["exec_access_ok"] = True
1289
+ report["exec_access_detail"] = "no recorded launchers"
1290
+ return
1291
+ codes = []
1292
+ for path in paths:
1293
+ result = _run(["icacls", path])
1294
+ text = (result.stdout or "") if result and result.returncode == 0 else ""
1295
+ codes.append(0 if windows_path_executable(text) else 1)
1296
+ problem = exec_access_problem(codes)
1297
+ report["exec_access_ok"] = problem is None
1298
+ report["exec_access_detail"] = problem or "recorded paths are executable"
1299
+ return
1300
+ switcher = state.get("wrapper") or ""
1301
+ owner = state.get("owner") or ""
1302
+ home = state.get("owner_home") or ""
1303
+ cred_path = os.path.join(home, ".claude", ".credentials.json") if home else ""
1304
+ probe = {"attempted": False, "owner": owner, "keychain_account": None, "credentials_account": None}
1305
+ if switcher and os.path.isfile(switcher):
1306
+ probe["attempted"] = True
1307
+ if platform == "darwin" and owner:
1308
+ keychain = _run([
1309
+ switcher, "security", "find-generic-password", "-a", owner, "-s", "Claude Code",
1310
+ ])
1311
+ if keychain and keychain.returncode == 0:
1312
+ probe["keychain_account"] = owner
1313
+ if cred_path:
1314
+ cred = _run([switcher, "/bin/cat", cred_path])
1315
+ if cred and cred.returncode == 0:
1316
+ probe["credentials_account"] = credentials_account(cred.stdout, owner)
1317
+ status, detail = classify_login(probe)
1318
+ report["claude_login"] = status
1319
+ report["claude_login_detail"] = detail
1320
+ rows = []
1321
+ ps = _run(["ps", "-ax", "-o", "uid=,command="])
1322
+ if ps and ps.returncode == 0:
1323
+ rows = parse_ps_uid_command(ps.stdout)
1324
+ service = any(os.path.isfile(path) for path in daemon_service_paths(home))
1325
+ running, service_only = classify_daemon(rows, state.get("agent_uid"), service)
1326
+ report["daemon_running_as_owner"] = running
1327
+ report["daemon_service_file"] = service_only
1328
+ paths = []
1329
+ launcher = state.get("claude_wrapper") or ""
1330
+ if launcher:
1331
+ paths.append(launcher)
1332
+ paths.extend(state.get("exec_paths") or [])
1333
+ if not paths:
1334
+ report["exec_access_ok"] = True
1335
+ report["exec_access_detail"] = "no recorded launchers"
1336
+ return
1337
+ if not switcher:
1338
+ report["exec_access_ok"] = False
1339
+ report["exec_access_detail"] = "a recorded path is not executable"
1340
+ return
1341
+ codes = []
1342
+ for path in paths:
1343
+ result = _run([switcher, "test", "-x", path])
1344
+ codes.append(1 if result is None else result.returncode)
1345
+ problem = exec_access_problem(codes)
1346
+ report["exec_access_ok"] = problem is None
1347
+ report["exec_access_detail"] = problem or "recorded paths are executable"