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/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
|
+
|