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/install.py ADDED
@@ -0,0 +1,1024 @@
1
+ """Install and uninstall. Mutating commands go through `runner`, which tests replace.
2
+
3
+ Nothing in this module runs at import time. `runner` defaults to a subprocess
4
+ call, so unit tests must not invoke cmd_install or cmd_uninstall.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import shutil
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+
14
+ from . import doctor
15
+ from .discover import (
16
+ choose_port,
17
+ collect_candidates,
18
+ controller_candidate,
19
+ default_fetch,
20
+ mihomo_home,
21
+ read_secret_stdin,
22
+ verge_paths,
23
+ )
24
+ from .errors import AgentLockError, NotInstalled, Unsupported, UsageError
25
+ from .linux import (
26
+ NFT_CONF,
27
+ UNIT_NAME,
28
+ UNIT_PATH,
29
+ classify_path,
30
+ credential_entries,
31
+ find_nft,
32
+ find_runuser,
33
+ nft_check_argv,
34
+ nft_load_argv,
35
+ render_nft,
36
+ render_unit,
37
+ require_firewalld_inactive,
38
+ setfacl_commands,
39
+ user_plan,
40
+ )
41
+ from .darwin import (
42
+ ANCHOR_PATH,
43
+ PF_BACKUP,
44
+ PF_CONF,
45
+ PF_DYNAMIC_ANCHOR_WARNING,
46
+ acl_commands,
47
+ check_conf_argv,
48
+ enable_argv,
49
+ is_tcc_path,
50
+ load_anchor_argv,
51
+ load_conf_argv,
52
+ parse_enable_token,
53
+ patch_pf_conf,
54
+ release_token_argv,
55
+ render_anchor,
56
+ unpatch_pf_conf,
57
+ user_plan as darwin_user_plan,
58
+ )
59
+ from .state import (
60
+ AGENT_USER,
61
+ CLAUDE_WRAPPER_PATH,
62
+ CREDENTIAL_DIRS,
63
+ LINUX_AGENT_HOME,
64
+ SUDOERS_PATH,
65
+ SWITCHER_PATH,
66
+ WINDOWS_CMD,
67
+ _within,
68
+ apply_install_fields,
69
+ atomic_write,
70
+ config_base,
71
+ is_root,
72
+ load_state,
73
+ merge_claude_settings,
74
+ new_state,
75
+ proxy_url,
76
+ resolve_owner,
77
+ save_state,
78
+ strip_claude_settings,
79
+ )
80
+ from .windows import (
81
+ build_rules,
82
+ create_profile_script,
83
+ icacls_commands,
84
+ link_plan,
85
+ new_local_user_script,
86
+ render_rule_script,
87
+ require_supported,
88
+ rule_names,
89
+ rule_phases,
90
+ share_icacls,
91
+ uninstall_commands,
92
+ user_action,
93
+ windows_admin_script,
94
+ )
95
+ from .wrapper import render_claude, render_sudoers, render_switcher, render_windows_cmd
96
+ from .linux import bind_bin_mode, validate_bind_bin
97
+
98
+
99
+ def _runner(argv, check=False, input_text=None, timeout=60):
100
+ return subprocess.run(
101
+ argv,
102
+ input=input_text,
103
+ stdout=subprocess.PIPE,
104
+ stderr=subprocess.PIPE,
105
+ universal_newlines=True,
106
+ timeout=timeout,
107
+ check=check,
108
+ )
109
+
110
+
111
+ def _ok(runner, argv, input_text=None, unsupported=False):
112
+ result = runner(argv, input_text=input_text)
113
+ if getattr(result, "returncode", 1) != 0:
114
+ detail = (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()
115
+ if unsupported:
116
+ base = os.path.basename(str(argv[0]))
117
+ if base == "nft":
118
+ raise Unsupported(
119
+ "nft must understand destroy and named counters: {}".format(
120
+ detail or "syntax check failed"))
121
+ raise Unsupported("pf syntax check failed: {}".format(detail or "pfctl -nf failed"))
122
+ raise AgentLockError("{} failed: {}".format(argv[0], detail or "exit {}".format(
123
+ getattr(result, "returncode", 1))))
124
+ return result
125
+
126
+
127
+ def _read_text(path):
128
+ try:
129
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
130
+ return handle.read()
131
+ except OSError:
132
+ return None
133
+
134
+
135
+ def _expand(path, owner_home):
136
+ if path == "~":
137
+ return owner_home
138
+ if path.startswith("~/") or path.startswith("~\\"):
139
+ return os.path.join(owner_home, path[2:])
140
+ if os.path.isabs(path):
141
+ return path
142
+ return os.path.abspath(path)
143
+
144
+
145
+ def _confine(path, owner_home, kind):
146
+ """realpath under the installing user's home. Symlinks and .. escapes are refused."""
147
+ expanded = _expand(path, owner_home)
148
+ if os.path.lexists(expanded) and os.path.islink(expanded):
149
+ raise UsageError("refusing a symlink: {}".format(path))
150
+ real = os.path.realpath(expanded)
151
+ home = os.path.realpath(owner_home)
152
+ if not _within(real, home):
153
+ raise UsageError("path is outside the installing user's home: {}".format(path))
154
+ if kind == "share" and os.path.normcase(os.path.normpath(real)) == os.path.normcase(os.path.normpath(home)):
155
+ raise UsageError("refusing to share the home directory")
156
+ if sys.platform == "darwin" and is_tcc_path(real, home):
157
+ raise UsageError("path is in Desktop, Documents, or Downloads: {}".format(path))
158
+ return real
159
+
160
+
161
+ def _claude_dirname_ok(directory):
162
+ name = os.path.basename(os.path.normpath(directory))
163
+ return name == ".claude" or name.endswith(".claude")
164
+
165
+
166
+ def _state_after_install(old, owner, port, extra, found):
167
+ """--port keeps a stored controller and secret. A real discovery may clear them."""
168
+ controller = None
169
+ secret = None
170
+ if found and found.get("source") != "--port":
171
+ controller = found.get("controller")
172
+ if controller is None:
173
+ controller = ""
174
+ secret = found.get("secret")
175
+ if secret is None:
176
+ secret = ""
177
+ if not old:
178
+ return new_state(owner, port, controller or "", secret or "", extra)
179
+ return apply_install_fields(old, port, extra, controller, secret)
180
+
181
+
182
+ def _owner_env(owner):
183
+ env = dict(os.environ)
184
+ if owner.get("sudo"):
185
+ for key in ("MIHOMO_CLI_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "APPDATA"):
186
+ value = env.get(key) or ""
187
+ if value.startswith("/root") or value.startswith("/var/root"):
188
+ env.pop(key, None)
189
+ return env
190
+
191
+
192
+ def _process_argvs(runner):
193
+ found = []
194
+ if sys.platform.startswith("linux"):
195
+ try:
196
+ names = os.listdir("/proc")
197
+ except OSError:
198
+ return found
199
+ for name in names:
200
+ if not name.isdigit():
201
+ continue
202
+ try:
203
+ with open(os.path.join("/proc", name, "cmdline"), "rb") as handle:
204
+ raw = handle.read()
205
+ except OSError:
206
+ continue
207
+ parts = [part.decode("utf-8", "replace") for part in raw.split(b"\0") if part]
208
+ if parts:
209
+ found.append(parts)
210
+ return found
211
+ if sys.platform == "darwin":
212
+ try:
213
+ result = runner(["ps", "-axww", "-o", "args="])
214
+ except OSError:
215
+ return found
216
+ import shlex
217
+ for line in (getattr(result, "stdout", "") or "").splitlines():
218
+ try:
219
+ found.append(shlex.split(line))
220
+ except ValueError:
221
+ continue
222
+ return found
223
+
224
+
225
+ def _gather(args, owner, runner):
226
+ secret = ""
227
+ if getattr(args, "secret_stdin", False):
228
+ secret = read_secret_stdin(sys.stdin.readline)
229
+ env = _owner_env(owner)
230
+ if args.controller:
231
+ candidates = [controller_candidate(args.controller, secret)]
232
+ else:
233
+ appdata = env.get("APPDATA") if sys.platform == "win32" else None
234
+ candidates = collect_candidates(
235
+ _process_argvs(runner),
236
+ _read_text,
237
+ owner.get("home"),
238
+ mihomo_home(owner.get("home"), env),
239
+ verge_paths(owner.get("home"), appdata),
240
+ owner.get("uid"),
241
+ )
242
+ found = choose_port(args.port, bool(args.controller), candidates, default_fetch)
243
+ return found
244
+
245
+
246
+ def _credential_roots(owner_home, share_dirs):
247
+ roots = []
248
+ for name in CREDENTIAL_DIRS:
249
+ path = os.path.join(owner_home, name)
250
+ if classify_path(path) != "missing":
251
+ roots.append(path)
252
+ for path in share_dirs:
253
+ roots.append(path)
254
+ return roots
255
+
256
+
257
+ def _account_dirs(owner):
258
+ base = config_base(owner)
259
+ if not base:
260
+ return []
261
+ path = os.path.join(base, "multi-claude", "config.json")
262
+ text = _read_text(path)
263
+ if not text:
264
+ return []
265
+ try:
266
+ data = json.loads(text)
267
+ except ValueError:
268
+ return []
269
+ root = _expand(data.get("root") or "~/.cc", owner["home"])
270
+ accounts = data.get("accounts") or {}
271
+ paths = []
272
+ if isinstance(accounts, dict):
273
+ for name, account in accounts.items():
274
+ directory = name
275
+ if isinstance(account, dict) and account.get("dir"):
276
+ directory = account["dir"]
277
+ paths.append(os.path.join(root, str(directory)))
278
+ return paths
279
+
280
+
281
+ def _chown_installer(path, owner):
282
+ """Root's write is given back to the installing user. Not used for switcher or firewall files."""
283
+ if sys.platform == "win32" or not is_root() or not owner or not owner.get("uid"):
284
+ return
285
+ import pwd
286
+ try:
287
+ gid = pwd.getpwuid(int(owner["uid"])).pw_gid
288
+ except KeyError:
289
+ return
290
+ os.chown(path, int(owner["uid"]), gid)
291
+
292
+
293
+ def _write_settings(paths, wrapper, owner):
294
+ """POSIX only. Records {path, created}. Does not write proxy variables."""
295
+ written = []
296
+ for path in paths:
297
+ directory = os.path.dirname(path)
298
+ if not directory or not _claude_dirname_ok(directory):
299
+ continue
300
+ if os.path.islink(directory):
301
+ raise AgentLockError("refusing a symlinked settings directory: {}".format(directory))
302
+ created_dir = False
303
+ if not os.path.isdir(directory):
304
+ os.makedirs(directory, exist_ok=True)
305
+ created_dir = True
306
+ if os.path.islink(path):
307
+ raise AgentLockError("refusing to follow a symlinked settings file: {}".format(path))
308
+ created = not os.path.isfile(path)
309
+ previous_mode = 0o600
310
+ document = {}
311
+ if os.path.isfile(path):
312
+ st = os.lstat(path)
313
+ previous_mode = stat.S_IMODE(st.st_mode)
314
+ text = _read_text(path) or ""
315
+ try:
316
+ document = json.loads(text) if text.strip() else {}
317
+ except ValueError:
318
+ raise AgentLockError("cannot parse Claude settings: {}".format(path))
319
+ document = merge_claude_settings(document, wrapper)
320
+ atomic_write(path, json.dumps(document, indent=2, sort_keys=True) + "\n", previous_mode)
321
+ _chown_installer(path, owner)
322
+ if created_dir:
323
+ _chown_installer(directory, owner)
324
+ written.append({"path": path, "created": created})
325
+ return written
326
+
327
+
328
+ def _snapshot(paths):
329
+ saved = []
330
+ for path in paths:
331
+ try:
332
+ st = os.lstat(path)
333
+ with open(path, "rb") as handle:
334
+ saved.append((path, handle.read(), stat.S_IMODE(st.st_mode), st.st_uid, st.st_gid))
335
+ except OSError:
336
+ saved.append((path, None, None, None, None))
337
+ return saved
338
+
339
+
340
+ def _restore(saved):
341
+ for item in saved:
342
+ path, data, mode = item[0], item[1], item[2]
343
+ uid = item[3] if len(item) > 3 else None
344
+ gid = item[4] if len(item) > 4 else None
345
+ if data is None:
346
+ if os.path.lexists(path) and not os.path.isdir(path):
347
+ os.remove(path)
348
+ continue
349
+ atomic_write(path, data, mode or 0o644)
350
+ if uid is not None and gid is not None and is_root():
351
+ try:
352
+ os.chown(path, uid, gid)
353
+ except OSError:
354
+ pass
355
+
356
+
357
+ def _stop_claude_daemon(runner):
358
+ if shutil.which("claude") is None:
359
+ sys.stderr.write("warning: claude is not on PATH; skipped daemon stop\n")
360
+ return
361
+ result = runner(["claude", "daemon", "stop", "--any"])
362
+ if getattr(result, "returncode", 1) != 0:
363
+ raise AgentLockError("claude daemon stop failed", 1)
364
+
365
+
366
+ def _share_paths(args, owner):
367
+ paths = []
368
+ for path in args.share_dir or []:
369
+ real = _confine(path, owner["home"], "share")
370
+ if not os.path.isdir(real) or os.path.islink(real):
371
+ raise UsageError("share-dir is not a directory: {}".format(path))
372
+ paths.append(real)
373
+ return paths
374
+
375
+
376
+ def _trust_paths(args, owner):
377
+ paths = []
378
+ for path in args.trust_exec or []:
379
+ real = _confine(path, owner["home"], "trust")
380
+ if os.path.islink(real):
381
+ raise UsageError("refusing a symlink: {}".format(path))
382
+ paths.append(real)
383
+ return paths
384
+
385
+
386
+ def _posix_settings_paths(args, owner):
387
+ paths = []
388
+ home_dir = os.path.join(owner["home"], ".claude")
389
+ if _claude_dirname_ok(home_dir):
390
+ paths.append(os.path.join(home_dir, "settings.json"))
391
+ for path in args.claude_dir or []:
392
+ directory = _confine(path, owner["home"], "claude")
393
+ if not _claude_dirname_ok(directory):
394
+ raise UsageError("claude-dir must be named .claude")
395
+ paths.append(os.path.join(directory, "settings.json"))
396
+ for path in _account_dirs(owner):
397
+ try:
398
+ directory = _confine(path, owner["home"], "claude")
399
+ except UsageError:
400
+ continue
401
+ if _claude_dirname_ok(directory):
402
+ paths.append(os.path.join(directory, "settings.json"))
403
+ return paths
404
+
405
+
406
+ def _refuse_groups(runner, names, flag):
407
+ result = runner(["id", flag, AGENT_USER])
408
+ groups = (getattr(result, "stdout", "") or "").split()
409
+ for name in names:
410
+ if name in groups:
411
+ raise AgentLockError("agentnet is in the {} group".format(name))
412
+
413
+
414
+ def _pf_reload(runner, path, strict):
415
+ sys.stderr.write(PF_DYNAMIC_ANCHOR_WARNING)
416
+ if strict:
417
+ _ok(runner, load_conf_argv(path))
418
+ else:
419
+ runner(load_conf_argv(path))
420
+
421
+
422
+ def _reload_darwin_firewall(runner):
423
+ """pfctl -f the restored main ruleset, then the restored anchor if it is still a file."""
424
+ _pf_reload(runner, PF_CONF, False)
425
+ if os.path.isfile(ANCHOR_PATH) and not os.path.islink(ANCHOR_PATH):
426
+ runner(load_anchor_argv(ANCHOR_PATH))
427
+
428
+
429
+ def _linux_identity():
430
+ import grp
431
+ import pwd
432
+ try:
433
+ user = pwd.getpwnam(AGENT_USER)
434
+ user_exists = True
435
+ shell = user.pw_shell
436
+ uid = user.pw_uid
437
+ gid = user.pw_gid
438
+ except KeyError:
439
+ user_exists = False
440
+ shell = ""
441
+ uid = 0
442
+ gid = 0
443
+ try:
444
+ group = grp.getgrnam(AGENT_USER)
445
+ group_exists = True
446
+ if not user_exists:
447
+ gid = group.gr_gid
448
+ except KeyError:
449
+ group_exists = False
450
+ return user_exists, group_exists, os.path.isdir(LINUX_AGENT_HOME), shell, uid, gid
451
+
452
+
453
+ def _proxy_check(runner, prefix, proxy):
454
+ result = runner(list(prefix) + [
455
+ "curl", "-fsS", "--max-time", "5", "-x", proxy, "https://example.com",
456
+ ])
457
+ return getattr(result, "returncode", 1) == 0
458
+
459
+
460
+ def _install_sudoers(owner_name, runner):
461
+ text = render_sudoers(owner_name)
462
+ directory = os.path.dirname(SUDOERS_PATH)
463
+ fd, tmp = __import__("tempfile").mkstemp(dir=directory if os.path.isdir(directory) else None, prefix="agent-lock-sudoers.")
464
+ os.close(fd)
465
+ try:
466
+ atomic_write(tmp, text, 0o440)
467
+ _ok(runner, ["visudo", "-cf", tmp])
468
+ atomic_write(SUDOERS_PATH, text, 0o440)
469
+ finally:
470
+ if os.path.exists(tmp) and tmp != SUDOERS_PATH:
471
+ try:
472
+ os.remove(tmp)
473
+ except OSError:
474
+ pass
475
+
476
+
477
+ def _linux_apply(args, owner, found, extra, runner, stdout):
478
+ require_firewalld_inactive(runner)
479
+ nft = find_nft()
480
+ runuser = find_runuser()
481
+ if not nft or not runuser:
482
+ raise Unsupported("nft or runuser was not found")
483
+ user_exists, group_exists, home_exists, shell, uid, gid = _linux_identity()
484
+ commands, created = user_plan(user_exists, group_exists, home_exists, shell)
485
+ for command in commands:
486
+ _ok(runner, command)
487
+ if created or uid == 0:
488
+ user_exists, group_exists, home_exists, shell, uid, gid = _linux_identity()
489
+ if not user_exists:
490
+ raise AgentLockError("agentnet was not created")
491
+ _refuse_groups(runner, ("sudo", "wheel", "adm"), "-nG")
492
+ port = int(found["port"])
493
+ share = _share_paths(args, owner)
494
+ trust = _trust_paths(args, owner)
495
+ switcher = render_switcher(port, runuser, darwin=False)
496
+ claude = render_claude(port)
497
+ rules = render_nft(uid, port, extra)
498
+ saved = _snapshot([NFT_CONF, UNIT_PATH, SWITCHER_PATH, CLAUDE_WRAPPER_PATH, SUDOERS_PATH])
499
+ fd, tmp = __import__("tempfile").mkstemp(prefix="agent-lock-nft.")
500
+ os.close(fd)
501
+ try:
502
+ atomic_write(tmp, rules, 0o644)
503
+ _ok(runner, nft_check_argv(nft, tmp), unsupported=True)
504
+ os.makedirs(os.path.dirname(NFT_CONF), exist_ok=True)
505
+ atomic_write(NFT_CONF, rules, 0o644)
506
+ _ok(runner, nft_load_argv(nft, NFT_CONF))
507
+ finally:
508
+ if os.path.exists(tmp):
509
+ os.remove(tmp)
510
+ atomic_write(UNIT_PATH, render_unit(nft, NFT_CONF), 0o644)
511
+ runner(["systemctl", "daemon-reload"])
512
+ _ok(runner, ["systemctl", "enable", "--now", UNIT_NAME])
513
+ atomic_write(SWITCHER_PATH, switcher, 0o755)
514
+ atomic_write(CLAUDE_WRAPPER_PATH, claude, 0o755)
515
+ _install_sudoers(owner["name"], runner)
516
+ roots = _credential_roots(owner["home"], share)
517
+ entries = credential_entries(roots, owner["home"], classify_path)
518
+ for command in setfacl_commands(entries):
519
+ _ok(runner, command)
520
+ spec = "u:{}:r-x,m::r-x".format(AGENT_USER)
521
+ for path in trust:
522
+ if os.path.islink(path):
523
+ raise UsageError("refusing a symlink: {}".format(path))
524
+ _ok(runner, ["setfacl", "-h", "-m", spec, path])
525
+ setting_paths = _posix_settings_paths(args, owner)
526
+ settings_saved = _snapshot(setting_paths)
527
+ old = load_state()
528
+ try:
529
+ _stop_claude_daemon(runner)
530
+ except AgentLockError:
531
+ _restore(saved)
532
+ if old and old.get("port"):
533
+ _ok(runner, nft_load_argv(nft, NFT_CONF))
534
+ else:
535
+ runner([nft, "delete", "table", "inet", "agentlock"])
536
+ raise
537
+ settings = _write_settings(setting_paths, CLAUDE_WRAPPER_PATH, owner)
538
+ if not _proxy_check(runner, [runuser, "--preserve-environment", "-u", AGENT_USER, "--", SWITCHER_PATH], proxy_url(port)):
539
+ _restore(saved + settings_saved)
540
+ if old and old.get("port"):
541
+ _ok(runner, nft_load_argv(nft, NFT_CONF))
542
+ else:
543
+ runner([nft, "delete", "table", "inet", "agentlock"])
544
+ raise AgentLockError("proxy check failed; kept the previous port")
545
+ data = _state_after_install(old, owner, port, extra, found)
546
+ data["agent_uid"] = uid
547
+ data["agent_gid"] = gid
548
+ data["agent_home"] = LINUX_AGENT_HOME
549
+ data["runuser_path"] = runuser
550
+ data["wrapper"] = SWITCHER_PATH
551
+ data["claude_wrapper"] = CLAUDE_WRAPPER_PATH
552
+ data["created_user"] = bool(created or (old or {}).get("created_user"))
553
+ data["share_dirs"] = share
554
+ data["exec_paths"] = trust
555
+ data["claude_settings"] = settings
556
+ data["pf_enable_token"] = ""
557
+ save_state(data, owner=owner)
558
+ for line in found.get("logs") or []:
559
+ stdout.write(line + "\n")
560
+ stdout.write("installed agent-lock on port {}\n".format(port))
561
+ return 0
562
+
563
+
564
+ def _darwin_apply(args, owner, found, extra, runner, stdout):
565
+ if not shutil.which("pfctl"):
566
+ raise Unsupported("pfctl was not found")
567
+ import pwd
568
+ try:
569
+ pwd.getpwnam(AGENT_USER)
570
+ exists = True
571
+ except KeyError:
572
+ exists = False
573
+ if not exists:
574
+ password = subprocess.run(
575
+ ["openssl", "rand", "-base64", "32"],
576
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, check=False)
577
+ secret_pw = (password.stdout or "").strip()
578
+ if not secret_pw:
579
+ raise AgentLockError("could not generate a password")
580
+ for command in darwin_user_plan():
581
+ _ok(runner, command, input_text=secret_pw + "\n" if command[0] == "sysadminctl" else None)
582
+ secret_pw = ""
583
+ _refuse_groups(runner, ("admin",), "-Gn")
584
+ share = _share_paths(args, owner)
585
+ trust = _trust_paths(args, owner)
586
+ port = int(found["port"])
587
+ anchor = render_anchor(port, extra)
588
+ current = _read_text(PF_CONF) or ""
589
+ already = 'anchor "agentlock"' in current
590
+ saved = _snapshot([PF_CONF, ANCHOR_PATH, SWITCHER_PATH, CLAUDE_WRAPPER_PATH])
591
+ if not already:
592
+ patched = patch_pf_conf(current)
593
+ if not os.path.exists(PF_BACKUP):
594
+ atomic_write(PF_BACKUP, current, 0o644)
595
+ fd, tmp = __import__("tempfile").mkstemp(prefix="agent-lock-pf.")
596
+ os.close(fd)
597
+ try:
598
+ atomic_write(tmp, patched, 0o644)
599
+ _ok(runner, check_conf_argv(tmp), unsupported=True)
600
+ atomic_write(PF_CONF, patched, 0o644)
601
+ _pf_reload(runner, PF_CONF, True)
602
+ finally:
603
+ if os.path.exists(tmp):
604
+ os.remove(tmp)
605
+ os.makedirs(os.path.dirname(ANCHOR_PATH), exist_ok=True)
606
+ atomic_write(ANCHOR_PATH, anchor, 0o644)
607
+ _ok(runner, load_anchor_argv(ANCHOR_PATH))
608
+ token = (load_state() or {}).get("pf_enable_token") or ""
609
+ if not token:
610
+ enabled = _ok(runner, enable_argv())
611
+ token = parse_enable_token(getattr(enabled, "stdout", "") + "\n" + getattr(enabled, "stderr", ""))
612
+ atomic_write(SWITCHER_PATH, render_switcher(port, "", darwin=True), 0o755)
613
+ atomic_write(CLAUDE_WRAPPER_PATH, render_claude(port), 0o755)
614
+ directories = [path for path in _credential_roots(owner["home"], share) if classify_path(path) == "dir"]
615
+ for command in acl_commands(directories, owner["home"]):
616
+ if command[1] == "-a":
617
+ runner(command)
618
+ else:
619
+ _ok(runner, command)
620
+ setting_paths = _posix_settings_paths(args, owner)
621
+ settings_saved = _snapshot(setting_paths)
622
+ try:
623
+ _stop_claude_daemon(runner)
624
+ except AgentLockError:
625
+ _restore(saved)
626
+ _reload_darwin_firewall(runner)
627
+ raise
628
+ settings = _write_settings(setting_paths, CLAUDE_WRAPPER_PATH, owner)
629
+ if not _proxy_check(
630
+ runner, ["/usr/bin/sudo", "--preserve-env", "-u", AGENT_USER, "--", SWITCHER_PATH], proxy_url(port)):
631
+ _restore(saved + settings_saved)
632
+ if already:
633
+ runner(load_anchor_argv(ANCHOR_PATH))
634
+ else:
635
+ _reload_darwin_firewall(runner)
636
+ raise AgentLockError("proxy check failed; kept the previous port")
637
+ old = load_state()
638
+ data = _state_after_install(old, owner, port, extra, found)
639
+ try:
640
+ user = pwd.getpwnam(AGENT_USER)
641
+ data["agent_uid"] = user.pw_uid
642
+ data["agent_gid"] = user.pw_gid
643
+ except KeyError:
644
+ pass
645
+ try:
646
+ data["agent_home"] = pwd.getpwnam(AGENT_USER).pw_dir
647
+ except KeyError:
648
+ data["agent_home"] = ""
649
+ data["runuser_path"] = ""
650
+ data["wrapper"] = SWITCHER_PATH
651
+ data["claude_wrapper"] = CLAUDE_WRAPPER_PATH
652
+ data["created_user"] = bool((not exists) or (old or {}).get("created_user"))
653
+ data["share_dirs"] = share
654
+ data["exec_paths"] = trust
655
+ data["claude_settings"] = settings
656
+ data["pf_enable_token"] = token
657
+ save_state(data, owner=owner)
658
+ for line in found.get("logs") or []:
659
+ stdout.write(line + "\n")
660
+ stdout.write("installed agent-lock on port {}\n".format(port))
661
+ return 0
662
+
663
+
664
+ def _windows_profiles(runner):
665
+ script = (
666
+ "Get-NetFirewallProfile | "
667
+ "Select-Object Name, AllowLocalFirewallRules, AllowLocalPolicyMerge, DefaultOutboundAction | "
668
+ "ConvertTo-Json -Compress"
669
+ )
670
+ result = runner(["powershell", "-NoProfile", "-Command", script])
671
+ raw = (getattr(result, "stdout", "") or "").strip()
672
+ if not raw:
673
+ raise AgentLockError("could not read Windows firewall profiles")
674
+ try:
675
+ data = json.loads(raw)
676
+ except ValueError:
677
+ raise AgentLockError("could not read Windows firewall profiles")
678
+ if isinstance(data, dict):
679
+ return [data]
680
+ return list(data)
681
+
682
+
683
+ def _windows_apply(args, owner, found, extra, runner, stdout):
684
+ require_supported(_windows_profiles(runner))
685
+ port = int(found["port"])
686
+ script = (
687
+ "$u = Get-LocalUser -Name '{user}' -ErrorAction SilentlyContinue\n"
688
+ "if ($null -eq $u) {{ 'missing' }} elseif ($u.Enabled) {{ 'enabled' }} else {{ 'disabled' }}\n"
689
+ ).format(user=AGENT_USER)
690
+ result = runner(["powershell", "-NoProfile", "-Command", script])
691
+ text = (getattr(result, "stdout", "") or "").strip()
692
+ if text == "disabled":
693
+ action = user_action(True, False)
694
+ elif text == "enabled":
695
+ action = user_action(True, True)
696
+ else:
697
+ action = user_action(False, True)
698
+ if action == "create":
699
+ _ok(runner, ["powershell", "-NoProfile", "-Command", new_local_user_script()])
700
+ admin = runner(["powershell", "-NoProfile", "-Command", windows_admin_script()])
701
+ if (getattr(admin, "stdout", "") or "").strip().lower() == "yes":
702
+ raise AgentLockError("agentnet is in the Administrators group")
703
+ sid_result = _ok(runner, ["powershell", "-NoProfile", "-Command",
704
+ "(Get-LocalUser -Name '{}').Sid.Value".format(AGENT_USER)])
705
+ sid = (sid_result.stdout or "").strip()
706
+ if not sid.startswith("S-"):
707
+ raise AgentLockError("could not read the agentnet SID")
708
+ old = load_state()
709
+ rules = build_rules(port, extra, sid)
710
+ new_names = rule_names(rules)
711
+ create_names, remove_names = rule_phases((old or {}).get("windows_rules"), new_names)
712
+ by_name = {rule["name"]: rule for rule in rules}
713
+ for name in create_names:
714
+ _ok(runner, ["powershell", "-NoProfile", "-Command", render_rule_script([by_name[name]]).strip()])
715
+ for command in uninstall_commands(remove_names):
716
+ runner(["powershell", "-NoProfile", "-Command", " ".join(command)])
717
+ home = owner.get("home") or ""
718
+ share = _share_paths(args, owner)
719
+ trust = _trust_paths(args, owner)
720
+ links = []
721
+ agent_home = os.path.join(os.environ.get("SystemDrive", "C:") + "\\", "Users", AGENT_USER)
722
+ for name in CREDENTIAL_DIRS:
723
+ target = os.path.join(home, name)
724
+ if os.path.islink(target):
725
+ continue
726
+ if os.path.isdir(target):
727
+ links.append((os.path.join(agent_home, name), target))
728
+ _ok(runner, ["powershell", "-NoProfile", "-Command", create_profile_script(sid)])
729
+ for command in link_plan(links):
730
+ _ok(runner, command)
731
+ cmd_saved = _snapshot([WINDOWS_CMD])
732
+ atomic_write(WINDOWS_CMD, render_windows_cmd(port, sid), 0o644)
733
+ for command in share_icacls([target for _link, target in links]):
734
+ runner(command)
735
+ for command in icacls_commands(home, trust, os.path.isdir):
736
+ runner(command)
737
+ if not _proxy_check(runner, [], proxy_url(port)):
738
+ _restore(cmd_saved)
739
+ for command in uninstall_commands(create_names):
740
+ runner(["powershell", "-NoProfile", "-Command", " ".join(command)])
741
+ if old and old.get("port") and remove_names:
742
+ previous = build_rules(old["port"], old.get("extra_tcp") or [], sid)
743
+ restore_names = set(remove_names)
744
+ for rule in previous:
745
+ if rule["name"] in restore_names:
746
+ runner(["powershell", "-NoProfile", "-Command", render_rule_script([rule]).strip()])
747
+ raise AgentLockError("proxy check failed; kept the previous port")
748
+ data = _state_after_install(old, owner, port, extra, found)
749
+ data["agent_sid"] = sid
750
+ data["agent_home"] = agent_home
751
+ data["wrapper"] = WINDOWS_CMD
752
+ data["claude_wrapper"] = ""
753
+ data["claude_settings"] = []
754
+ data["windows_rules"] = rule_names(rules)
755
+ data["links"] = [link for link, _target in links]
756
+ data["share_dirs"] = share
757
+ data["exec_paths"] = trust
758
+ data["created_user"] = bool(action == "create" or (old or {}).get("created_user"))
759
+ data["runuser_path"] = ""
760
+ save_state(data, owner=owner)
761
+ for line in found.get("logs") or []:
762
+ stdout.write(line + "\n")
763
+ stdout.write("installed agent-lock on port {}\n".format(port))
764
+ return 0
765
+
766
+
767
+ def cmd_install(args, stdout=None, stderr=None, runner=None):
768
+ stdout = stdout or sys.stdout
769
+ runner = runner or _runner
770
+ if sys.platform != "win32" and not is_root():
771
+ raise AgentLockError("run sudo agent-lock install", 1)
772
+ owner = resolve_owner()
773
+ if owner is None:
774
+ raise AgentLockError("cannot resolve the installing user; refusing to write state as root", 1)
775
+ extra = list(args.allow_tcp or [])
776
+ if sys.platform.startswith("linux"):
777
+ found = _gather(args, owner, runner)
778
+ return _linux_apply(args, owner, found, extra, runner, stdout)
779
+ if sys.platform == "darwin":
780
+ found = _gather(args, owner, runner)
781
+ return _darwin_apply(args, owner, found, extra, runner, stdout)
782
+ if sys.platform == "win32":
783
+ found = _gather(args, owner, runner)
784
+ return _windows_apply(args, owner, found, extra, runner, stdout)
785
+ raise Unsupported("this operating system is not supported")
786
+
787
+
788
+ def _remove_user_keep_home(state, runner):
789
+ """Remove the account record when we created it or it lives in the expected home.
790
+
791
+ The home directory is left on disk. There is no recursive home removal.
792
+ """
793
+ home = state.get("agent_home") or ""
794
+ if not (state.get("created_user") or home == LINUX_AGENT_HOME):
795
+ return
796
+ if sys.platform.startswith("linux"):
797
+ runner(["userdel", AGENT_USER])
798
+ elif sys.platform == "darwin" and state.get("created_user"):
799
+ runner(["dscl", ".", "-delete", "/Users/{}".format(AGENT_USER)])
800
+
801
+
802
+ def _is_junction(path):
803
+ """Windows reparse directory. A real directory is not a junction."""
804
+ if os.path.islink(path) or not os.path.isdir(path):
805
+ return False
806
+ if sys.platform != "win32":
807
+ return False
808
+ import ctypes
809
+ attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
810
+ if attrs in (-1, 0xFFFFFFFF):
811
+ return False
812
+ return bool(int(attrs) & 0x400)
813
+
814
+
815
+ def agent_uid_busy(text, agent_uid):
816
+ """True when a process uid matches agentnet. uid 0 is never treated as agentnet."""
817
+ try:
818
+ agent_uid = int(agent_uid)
819
+ except (TypeError, ValueError):
820
+ return False
821
+ if agent_uid <= 0:
822
+ return False
823
+ for line in (text or "").splitlines():
824
+ token = line.strip()
825
+ if token.isdigit() and int(token) == agent_uid:
826
+ return True
827
+ return False
828
+
829
+
830
+ def _refuse_if_agent_busy(state, runner):
831
+ if sys.platform == "win32":
832
+ sid = state.get("agent_sid") or ""
833
+ if not sid or any(ch not in "S-0123456789" for ch in sid) or not sid.startswith("S-1-"):
834
+ return
835
+ script = (
836
+ "Get-CimInstance Win32_Process | ForEach-Object { "
837
+ "$out = Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid; "
838
+ "if ($out.Sid -eq '" + sid + "') { 'busy' } }"
839
+ )
840
+ result = runner(["powershell", "-NoProfile", "-Command", script])
841
+ if result and "busy" in (getattr(result, "stdout", "") or "").lower():
842
+ raise AgentLockError("agentnet processes are still running", 1)
843
+ return
844
+ result = runner(["ps", "-ax", "-o", "uid="])
845
+ if agent_uid_busy(getattr(result, "stdout", "") if result else "", state.get("agent_uid")):
846
+ raise AgentLockError("agentnet processes are still running", 1)
847
+
848
+
849
+ def _unlink_recorded(path):
850
+ """Remove a symlink or a junction. A real directory is left untouched."""
851
+ if not path or not os.path.lexists(path):
852
+ return
853
+ if os.path.islink(path):
854
+ os.unlink(path)
855
+ return
856
+ if _is_junction(path):
857
+ os.rmdir(path)
858
+ return
859
+ raise AgentLockError("recorded link is not a symlink or junction; left it in place: {}".format(path))
860
+
861
+
862
+ def _strip_settings(entries, state=None):
863
+ owner_uid = (state or {}).get("owner_uid")
864
+ owner_gid = None
865
+ if owner_uid and is_root():
866
+ import pwd
867
+ try:
868
+ owner_gid = pwd.getpwuid(int(owner_uid)).pw_gid
869
+ owner_uid = int(owner_uid)
870
+ except (KeyError, TypeError, ValueError):
871
+ owner_uid = None
872
+ owner_gid = None
873
+ for entry in entries or []:
874
+ if not isinstance(entry, dict):
875
+ continue
876
+ path = entry.get("path")
877
+ if not path or os.path.islink(path) or not os.path.isfile(path):
878
+ continue
879
+ st = os.lstat(path)
880
+ mode = stat.S_IMODE(st.st_mode)
881
+ uid, gid = st.st_uid, st.st_gid
882
+ text = _read_text(path) or ""
883
+ try:
884
+ document = json.loads(text) if text.strip() else {}
885
+ except ValueError:
886
+ continue
887
+ document, empty = strip_claude_settings(document)
888
+ if entry.get("created") and empty:
889
+ os.remove(path)
890
+ continue
891
+ atomic_write(path, json.dumps(document, indent=2, sort_keys=True) + "\n", mode)
892
+ if is_root():
893
+ target_uid = owner_uid if owner_uid else uid
894
+ target_gid = owner_gid if owner_gid is not None else gid
895
+ try:
896
+ os.chown(path, target_uid, target_gid)
897
+ except OSError:
898
+ pass
899
+
900
+
901
+ def cmd_uninstall(args, stdout=None, stderr=None, runner=None):
902
+ stdout = stdout or sys.stdout
903
+ runner = runner or _runner
904
+ state = load_state()
905
+ if not state:
906
+ raise NotInstalled()
907
+ _refuse_if_agent_busy(state, runner)
908
+ for path in state.get("links") or []:
909
+ _unlink_recorded(path)
910
+ if sys.platform.startswith("linux"):
911
+ runner(["systemctl", "disable", "--now", UNIT_NAME])
912
+ nft = find_nft() or "nft"
913
+ runner([nft, "delete", "table", "inet", "agentlock"])
914
+ for path in (UNIT_PATH, NFT_CONF, SUDOERS_PATH, SWITCHER_PATH, CLAUDE_WRAPPER_PATH):
915
+ if os.path.lexists(path) and not os.path.isdir(path):
916
+ os.remove(path)
917
+ runner(["systemctl", "daemon-reload"])
918
+ elif sys.platform == "darwin":
919
+ current = _read_text(PF_CONF)
920
+ if current is not None:
921
+ atomic_write(PF_CONF, unpatch_pf_conf(current), 0o644)
922
+ if os.path.lexists(ANCHOR_PATH) and not os.path.islink(ANCHOR_PATH):
923
+ os.remove(ANCHOR_PATH)
924
+ runner(check_conf_argv(PF_CONF))
925
+ _pf_reload(runner, PF_CONF, False)
926
+ release = release_token_argv(state.get("pf_enable_token") or "")
927
+ if release:
928
+ runner(release)
929
+ for path in (SWITCHER_PATH, CLAUDE_WRAPPER_PATH):
930
+ if os.path.lexists(path) and not os.path.isdir(path):
931
+ os.remove(path)
932
+ elif sys.platform == "win32":
933
+ for command in uninstall_commands(state.get("windows_rules") or []):
934
+ runner(["powershell", "-NoProfile", "-Command", " ".join(command)])
935
+ if os.path.isfile(WINDOWS_CMD):
936
+ os.remove(WINDOWS_CMD)
937
+ if sys.platform != "win32":
938
+ _strip_settings(state.get("claude_settings"), state)
939
+ _remove_user_keep_home(state, runner)
940
+ path = None
941
+ from .state import state_path
942
+ path = state_path()
943
+ if path and os.path.isfile(path):
944
+ os.remove(path)
945
+ stdout.write("uninstalled agent-lock; linked directories were kept\n")
946
+ return 0
947
+
948
+
949
+ def cmd_exec(tail):
950
+ if not tail:
951
+ raise UsageError("missing command after --")
952
+ state = load_state()
953
+ wrapper = (state or {}).get("wrapper")
954
+ if not state or not wrapper or not os.path.isfile(wrapper):
955
+ raise NotInstalled()
956
+ os.execv(wrapper, [wrapper] + list(tail))
957
+ return 1
958
+
959
+
960
+ def open_directory_nofollow(path):
961
+ """Open a directory fd that fails when the path is a symlink."""
962
+ flags = os.O_RDONLY
963
+ if hasattr(os, "O_DIRECTORY"):
964
+ flags |= os.O_DIRECTORY
965
+ if hasattr(os, "O_NOFOLLOW"):
966
+ flags |= os.O_NOFOLLOW
967
+ elif os.path.islink(path):
968
+ raise OSError("refusing a symlink")
969
+ return os.open(path, flags)
970
+
971
+
972
+ def prepare_bind_dir(path, euid, owner_uid, gid):
973
+ """fchown and fchmod the directory fd. A replaced symlink never receives the mode."""
974
+ if euid != 0:
975
+ raise AgentLockError("agent-lock: bind-bin needs root; run it with sudo", 1)
976
+ try:
977
+ fd = open_directory_nofollow(path)
978
+ except OSError:
979
+ raise UsageError("agent-lock: bind-bin refuses a symlink or a non-directory")
980
+ try:
981
+ st = os.fstat(fd)
982
+ code, message = validate_bind_bin(
983
+ euid, False, stat.S_ISDIR(st.st_mode), st.st_uid, owner_uid)
984
+ if code == 2:
985
+ raise UsageError(message)
986
+ if code:
987
+ raise AgentLockError(message, code)
988
+ os.fchown(fd, -1, gid)
989
+ os.fchmod(fd, bind_bin_mode(stat.S_IMODE(st.st_mode)))
990
+ finally:
991
+ os.close(fd)
992
+
993
+
994
+ def cmd_bind_bin(args):
995
+ if sys.platform == "darwin":
996
+ raise Unsupported("bind-bin is not used on macOS")
997
+ if sys.platform == "win32":
998
+ raise Unsupported("bind-bin is not used on Windows")
999
+ euid = os.geteuid() if hasattr(os, "geteuid") else 0
1000
+ if euid != 0:
1001
+ raise AgentLockError("agent-lock: bind-bin needs root; run it with sudo", 1)
1002
+ owner = resolve_owner()
1003
+ if owner is None:
1004
+ raise AgentLockError("cannot resolve the installing user", 1)
1005
+ state = load_state()
1006
+ if not state:
1007
+ raise NotInstalled()
1008
+ import grp
1009
+ gid = grp.getgrnam(AGENT_USER).gr_gid
1010
+ prepare_bind_dir(args.path, euid, owner["uid"], gid)
1011
+ absolute = os.path.abspath(args.path)
1012
+ dirs = list(state.get("bin_dirs") or [])
1013
+ if absolute not in dirs:
1014
+ dirs.append(absolute)
1015
+ state["bin_dirs"] = dirs
1016
+ state["agent_gid"] = gid
1017
+ save_state(state)
1018
+ return 0
1019
+
1020
+
1021
+ def cmd_doctor(args, stdout=None, stderr=None):
1022
+ return doctor.run_doctor(args, stdout=stdout, stderr=stderr)
1023
+
1024
+