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/__init__.py +3 -0
- agent_lock/__main__.py +5 -0
- agent_lock/allow.py +40 -0
- agent_lock/cli.py +181 -0
- agent_lock/completion.py +414 -0
- agent_lock/darwin.py +276 -0
- agent_lock/discover.py +542 -0
- agent_lock/doctor.py +1347 -0
- agent_lock/errors.py +22 -0
- agent_lock/install.py +1024 -0
- agent_lock/linux.py +336 -0
- agent_lock/state.py +275 -0
- agent_lock/windows.py +706 -0
- agent_lock/wrapper.py +259 -0
- agent_lock_cli-0.1.1.dist-info/METADATA +139 -0
- agent_lock_cli-0.1.1.dist-info/RECORD +20 -0
- agent_lock_cli-0.1.1.dist-info/WHEEL +5 -0
- agent_lock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- agent_lock_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- agent_lock_cli-0.1.1.dist-info/top_level.txt +1 -0
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"
|