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/linux.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Linux nftables table, systemd oneshot, setfacl, and the agentnet user.
|
|
2
|
+
|
|
3
|
+
Nothing here loads a ruleset unless the caller runs nft.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
from .errors import AgentLockError, Unsupported
|
|
13
|
+
from .state import AGENT_USER, LINUX_AGENT_HOME, _within
|
|
14
|
+
|
|
15
|
+
NFT_CONF = "/etc/agent-lock/nftables.conf"
|
|
16
|
+
UNIT_PATH = "/etc/systemd/system/agent-lock-nft.service"
|
|
17
|
+
UNIT_NAME = "agent-lock-nft.service"
|
|
18
|
+
TABLE = "inet agentlock"
|
|
19
|
+
NOLOGIN = "/usr/sbin/nologin"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_nft(uid, port, extra_tcp):
|
|
23
|
+
"""One nft -f transaction: destroy, declare counters, then the chain."""
|
|
24
|
+
uid = int(uid)
|
|
25
|
+
port = int(port)
|
|
26
|
+
extras = _normalize_extra(extra_tcp)
|
|
27
|
+
lines = [
|
|
28
|
+
"destroy table inet agentlock",
|
|
29
|
+
"table inet agentlock {",
|
|
30
|
+
" counter agentlock_accept_v4 { }",
|
|
31
|
+
" counter agentlock_accept_v6 { }",
|
|
32
|
+
" counter agentlock_drop { }",
|
|
33
|
+
]
|
|
34
|
+
for extra_port in _extra_ports(extras):
|
|
35
|
+
lines.append(" counter agentlock_accept_v4_{0} {{ }}".format(extra_port))
|
|
36
|
+
lines.append(" counter agentlock_accept_v6_{0} {{ }}".format(extra_port))
|
|
37
|
+
lines.extend([
|
|
38
|
+
" chain output {",
|
|
39
|
+
" type filter hook output priority -10; policy accept;",
|
|
40
|
+
" meta skuid {uid} ip daddr 127.0.0.1 tcp dport {port} "
|
|
41
|
+
"counter name agentlock_accept_v4 accept".format(uid=uid, port=port),
|
|
42
|
+
" meta skuid {uid} ip6 daddr ::1 tcp dport {port} "
|
|
43
|
+
"counter name agentlock_accept_v6 accept".format(uid=uid, port=port),
|
|
44
|
+
])
|
|
45
|
+
for host, extra_port in extras:
|
|
46
|
+
if host == "127.0.0.1":
|
|
47
|
+
lines.append(
|
|
48
|
+
" meta skuid {uid} ip daddr 127.0.0.1 tcp dport {port} "
|
|
49
|
+
"counter name agentlock_accept_v4_{port} accept".format(uid=uid, port=extra_port))
|
|
50
|
+
else:
|
|
51
|
+
lines.append(
|
|
52
|
+
" meta skuid {uid} ip6 daddr ::1 tcp dport {port} "
|
|
53
|
+
"counter name agentlock_accept_v6_{port} accept".format(uid=uid, port=extra_port))
|
|
54
|
+
lines.extend([
|
|
55
|
+
" meta skuid {uid} counter name agentlock_drop drop".format(uid=uid),
|
|
56
|
+
" }",
|
|
57
|
+
"}",
|
|
58
|
+
"",
|
|
59
|
+
])
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _extra_ports(extras):
|
|
64
|
+
seen = []
|
|
65
|
+
for _host, extra_port in extras:
|
|
66
|
+
if extra_port not in seen:
|
|
67
|
+
seen.append(extra_port)
|
|
68
|
+
return seen
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_extra(extra_tcp):
|
|
72
|
+
from .allow import parse_allow_tcp_value
|
|
73
|
+
items = []
|
|
74
|
+
for value in extra_tcp or []:
|
|
75
|
+
items.append(parse_allow_tcp_value(value))
|
|
76
|
+
return items
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def render_unit(nft_bin="/usr/sbin/nft", conf_path=NFT_CONF):
|
|
80
|
+
return """[Unit]
|
|
81
|
+
Description=Load agent-lock nftables table
|
|
82
|
+
After=nftables.service ufw.service firewalld.service
|
|
83
|
+
Before=systemd-user-sessions.service display-manager.service
|
|
84
|
+
|
|
85
|
+
[Service]
|
|
86
|
+
Type=oneshot
|
|
87
|
+
RemainAfterExit=yes
|
|
88
|
+
ExecStart={nft} -f {conf}
|
|
89
|
+
ExecStop=-{nft} delete table inet agentlock
|
|
90
|
+
|
|
91
|
+
[Install]
|
|
92
|
+
WantedBy=multi-user.target
|
|
93
|
+
""".format(nft=shlex.quote(nft_bin), conf=shlex.quote(conf_path))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def bind_bin_mode(old):
|
|
97
|
+
"""(old | setgid | group read | group exec) with group write cleared."""
|
|
98
|
+
return (old | stat.S_ISGID | stat.S_IRGRP | stat.S_IXGRP) & ~stat.S_IWGRP
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_bind_bin(euid, is_link, is_dir, uid, owner_uid):
|
|
102
|
+
"""Return (exit code, message). 0 means the directory may be marked."""
|
|
103
|
+
if euid != 0:
|
|
104
|
+
return 1, "agent-lock: bind-bin needs root; run it with sudo"
|
|
105
|
+
if is_link or not is_dir:
|
|
106
|
+
return 2, "agent-lock: bind-bin refuses a symlink or a non-directory"
|
|
107
|
+
if uid != owner_uid:
|
|
108
|
+
return 1, "agent-lock: bind-bin refuses a directory not owned by the installing user"
|
|
109
|
+
return 0, ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def user_plan(user_exists, group_exists, home_exists, current_shell):
|
|
113
|
+
"""Commands to converge the account.
|
|
114
|
+
|
|
115
|
+
An existing user is not passed to useradd and its password is not changed.
|
|
116
|
+
--create-home is omitted when the home directory is already there.
|
|
117
|
+
passwd -l runs only for a user created in this round.
|
|
118
|
+
"""
|
|
119
|
+
commands = []
|
|
120
|
+
created = False
|
|
121
|
+
if user_exists:
|
|
122
|
+
if current_shell != NOLOGIN:
|
|
123
|
+
commands.append(["usermod", "--shell", NOLOGIN, AGENT_USER])
|
|
124
|
+
elif group_exists:
|
|
125
|
+
commands.append(_useradd(home_exists, group=True))
|
|
126
|
+
created = True
|
|
127
|
+
else:
|
|
128
|
+
commands.append(_useradd(home_exists, group=False))
|
|
129
|
+
created = True
|
|
130
|
+
if created:
|
|
131
|
+
commands.append(["passwd", "-l", AGENT_USER])
|
|
132
|
+
return commands, created
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _useradd(home_exists, group):
|
|
136
|
+
command = ["useradd", "--system"]
|
|
137
|
+
if group:
|
|
138
|
+
command.extend(["--gid", AGENT_USER])
|
|
139
|
+
else:
|
|
140
|
+
command.append("--user-group")
|
|
141
|
+
command.extend(["--home-dir", LINUX_AGENT_HOME, "--shell", NOLOGIN])
|
|
142
|
+
if not home_exists:
|
|
143
|
+
command.append("--create-home")
|
|
144
|
+
command.append(AGENT_USER)
|
|
145
|
+
return command
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def firewalld_is_active(runner):
|
|
149
|
+
try:
|
|
150
|
+
result = runner(["systemctl", "is-active", "firewalld.service"])
|
|
151
|
+
except (OSError, subprocess.SubprocessError):
|
|
152
|
+
return False
|
|
153
|
+
stdout = getattr(result, "stdout", "") or ""
|
|
154
|
+
code = getattr(result, "returncode", 1)
|
|
155
|
+
return code == 0 and stdout.strip() == "active"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def require_firewalld_inactive(runner):
|
|
159
|
+
if firewalld_is_active(runner):
|
|
160
|
+
raise Unsupported(
|
|
161
|
+
"firewalld is active; agent-lock does not install while firewalld would drop the table")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def accepted_system_tool(path):
|
|
165
|
+
"""Absolute canonical path under /usr or /bin. Rejects shell metacharacters."""
|
|
166
|
+
if not path or not os.path.isabs(path):
|
|
167
|
+
return None
|
|
168
|
+
if any(char in path for char in "\n\r\t ;&|$`'\"<>\\"):
|
|
169
|
+
return None
|
|
170
|
+
real = os.path.realpath(path)
|
|
171
|
+
if not (real.startswith("/usr/") or real.startswith("/bin/")):
|
|
172
|
+
return None
|
|
173
|
+
if any(char in real for char in "\n\r\t ;&|$`'\"<>"):
|
|
174
|
+
return None
|
|
175
|
+
return real
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def find_nft():
|
|
179
|
+
return accepted_system_tool(shutil.which("nft") or "")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def find_runuser():
|
|
183
|
+
return accepted_system_tool(shutil.which("runuser") or "")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def nft_check_argv(nft_bin, conf_path):
|
|
187
|
+
return [nft_bin, "-c", "-f", conf_path]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def nft_load_argv(nft_bin, conf_path):
|
|
191
|
+
return [nft_bin, "-f", conf_path]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _rule_match(rule):
|
|
195
|
+
"""Return (verdict, daddrs, dports) from one nft JSON rule."""
|
|
196
|
+
verdict = None
|
|
197
|
+
daddrs = []
|
|
198
|
+
dports = []
|
|
199
|
+
for item in rule.get("expr") or []:
|
|
200
|
+
if not isinstance(item, dict):
|
|
201
|
+
continue
|
|
202
|
+
if "accept" in item:
|
|
203
|
+
verdict = "accept"
|
|
204
|
+
elif "drop" in item:
|
|
205
|
+
verdict = "drop"
|
|
206
|
+
match = item.get("match")
|
|
207
|
+
if not isinstance(match, dict):
|
|
208
|
+
continue
|
|
209
|
+
left = match.get("left") if isinstance(match.get("left"), dict) else {}
|
|
210
|
+
payload = left.get("payload") if isinstance(left.get("payload"), dict) else None
|
|
211
|
+
if not payload:
|
|
212
|
+
continue
|
|
213
|
+
if payload.get("field") == "daddr":
|
|
214
|
+
daddrs.append(str(match.get("right")))
|
|
215
|
+
if payload.get("field") == "dport":
|
|
216
|
+
right = match.get("right")
|
|
217
|
+
if isinstance(right, bool):
|
|
218
|
+
continue
|
|
219
|
+
if isinstance(right, int):
|
|
220
|
+
dports.append(right)
|
|
221
|
+
elif isinstance(right, str) and right.isdigit():
|
|
222
|
+
dports.append(int(right))
|
|
223
|
+
return verdict, daddrs, dports
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def nft_observed(data, port):
|
|
227
|
+
"""(main accepts and a drop are present, extra loopback allows).
|
|
228
|
+
|
|
229
|
+
`data` is the object from `nft -j list table`. Extras are host:port strings
|
|
230
|
+
other than the mixed port. An empty list means no extra accept was loaded.
|
|
231
|
+
"""
|
|
232
|
+
port = int(port)
|
|
233
|
+
has_v4 = False
|
|
234
|
+
has_v6 = False
|
|
235
|
+
has_drop = False
|
|
236
|
+
extras = []
|
|
237
|
+
items = data.get("nftables") if isinstance(data, dict) else None
|
|
238
|
+
for item in items or []:
|
|
239
|
+
rule = item.get("rule") if isinstance(item, dict) else None
|
|
240
|
+
if not isinstance(rule, dict):
|
|
241
|
+
continue
|
|
242
|
+
verdict, daddrs, dports = _rule_match(rule)
|
|
243
|
+
if verdict == "drop":
|
|
244
|
+
has_drop = True
|
|
245
|
+
if verdict != "accept":
|
|
246
|
+
continue
|
|
247
|
+
for dport in dports:
|
|
248
|
+
for daddr in daddrs:
|
|
249
|
+
if daddr == "127.0.0.1" and dport == port:
|
|
250
|
+
has_v4 = True
|
|
251
|
+
elif daddr == "::1" and dport == port:
|
|
252
|
+
has_v6 = True
|
|
253
|
+
elif daddr in ("127.0.0.1", "::1") and dport != port:
|
|
254
|
+
value = "{}:{}".format(daddr, dport)
|
|
255
|
+
if value not in extras:
|
|
256
|
+
extras.append(value)
|
|
257
|
+
return has_v4 and has_v6 and has_drop, extras
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def parent_dirs(path, owner_home):
|
|
261
|
+
"""Parents from the owner home down to, but not including, path. Traverse only."""
|
|
262
|
+
path = os.path.abspath(path)
|
|
263
|
+
home = os.path.abspath(owner_home)
|
|
264
|
+
parents = []
|
|
265
|
+
current = os.path.dirname(path)
|
|
266
|
+
while current and current != os.path.dirname(current):
|
|
267
|
+
if _within(current, home):
|
|
268
|
+
parents.append(current)
|
|
269
|
+
if current == home:
|
|
270
|
+
break
|
|
271
|
+
current = os.path.dirname(current)
|
|
272
|
+
parents.reverse()
|
|
273
|
+
return parents
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def setfacl_commands(entries):
|
|
277
|
+
"""entries are (path, kind) with kind in {dir, file, parent}. No default ACL."""
|
|
278
|
+
commands = []
|
|
279
|
+
for path, kind in entries:
|
|
280
|
+
if kind == "dir":
|
|
281
|
+
spec = "u:{user}:rwX,m::rwX".format(user=AGENT_USER)
|
|
282
|
+
elif kind == "file":
|
|
283
|
+
spec = "u:{user}:rw-,m::rw-".format(user=AGENT_USER)
|
|
284
|
+
elif kind == "parent":
|
|
285
|
+
spec = "u:{user}:--x,m::--x".format(user=AGENT_USER)
|
|
286
|
+
else:
|
|
287
|
+
raise AgentLockError("unknown setfacl kind")
|
|
288
|
+
commands.append(["setfacl", "-h", "-m", spec, path])
|
|
289
|
+
return commands
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def credential_entries(roots, owner_home, classify):
|
|
293
|
+
"""classify(path) -> 'dir', 'file', 'link', or 'missing'. Symlinks are skipped."""
|
|
294
|
+
entries = []
|
|
295
|
+
seen_parents = set()
|
|
296
|
+
for root in roots:
|
|
297
|
+
if classify(root) == "missing":
|
|
298
|
+
continue
|
|
299
|
+
for parent in parent_dirs(root, owner_home):
|
|
300
|
+
if parent not in seen_parents:
|
|
301
|
+
seen_parents.add(parent)
|
|
302
|
+
entries.append((parent, "parent"))
|
|
303
|
+
_walk(root, classify, entries)
|
|
304
|
+
return entries
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _walk(path, classify, entries):
|
|
308
|
+
kind = classify(path)
|
|
309
|
+
if kind == "link" or kind == "missing":
|
|
310
|
+
return
|
|
311
|
+
if kind == "file":
|
|
312
|
+
entries.append((path, "file"))
|
|
313
|
+
return
|
|
314
|
+
if kind != "dir":
|
|
315
|
+
return
|
|
316
|
+
entries.append((path, "dir"))
|
|
317
|
+
try:
|
|
318
|
+
names = os.listdir(path)
|
|
319
|
+
except OSError:
|
|
320
|
+
return
|
|
321
|
+
for name in sorted(names):
|
|
322
|
+
_walk(os.path.join(path, name), classify, entries)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def classify_path(path):
|
|
326
|
+
try:
|
|
327
|
+
st = os.lstat(path)
|
|
328
|
+
except OSError:
|
|
329
|
+
return "missing"
|
|
330
|
+
if stat.S_ISLNK(st.st_mode):
|
|
331
|
+
return "link"
|
|
332
|
+
if stat.S_ISDIR(st.st_mode):
|
|
333
|
+
return "dir"
|
|
334
|
+
if stat.S_ISREG(st.st_mode):
|
|
335
|
+
return "file"
|
|
336
|
+
return "other"
|
agent_lock/state.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Owner state.json. Mode 0600. Never log the controller secret stored here."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
from .errors import AgentLockError
|
|
9
|
+
|
|
10
|
+
AGENT_USER = "agentnet"
|
|
11
|
+
SWITCHER_PATH = "/usr/local/sbin/agent-lock"
|
|
12
|
+
CLAUDE_WRAPPER_PATH = "/usr/local/sbin/agent-lock-claude"
|
|
13
|
+
SUDOERS_PATH = "/etc/sudoers.d/agent-lock"
|
|
14
|
+
LINUX_AGENT_HOME = "/var/lib/agentnet"
|
|
15
|
+
DARWIN_AGENT_HOME = "/Users/agentnet"
|
|
16
|
+
WINDOWS_DIR = os.path.join(os.environ.get("ProgramData", r"C:\ProgramData"), "agent-lock")
|
|
17
|
+
WINDOWS_CMD = os.path.join(WINDOWS_DIR, "agent-lock.cmd")
|
|
18
|
+
CREDENTIAL_DIRS = (".codex", ".cx", ".claude", ".cc")
|
|
19
|
+
|
|
20
|
+
STATE_DEFAULTS = {
|
|
21
|
+
"version": 1,
|
|
22
|
+
"owner": "",
|
|
23
|
+
"owner_uid": 0,
|
|
24
|
+
"owner_home": "",
|
|
25
|
+
"agent_user": AGENT_USER,
|
|
26
|
+
"agent_uid": 0,
|
|
27
|
+
"agent_gid": 0,
|
|
28
|
+
"agent_sid": "",
|
|
29
|
+
"agent_home": "",
|
|
30
|
+
"port": 0,
|
|
31
|
+
"proxy": "",
|
|
32
|
+
"controller": "",
|
|
33
|
+
"secret": "",
|
|
34
|
+
"wrapper": SWITCHER_PATH,
|
|
35
|
+
"claude_wrapper": CLAUDE_WRAPPER_PATH,
|
|
36
|
+
"runuser_path": "",
|
|
37
|
+
"extra_tcp": [],
|
|
38
|
+
"claude_settings": [],
|
|
39
|
+
"links": [],
|
|
40
|
+
"share_dirs": [],
|
|
41
|
+
"bin_dirs": [],
|
|
42
|
+
"exec_paths": [],
|
|
43
|
+
"windows_rules": [],
|
|
44
|
+
"pf_enable_token": "",
|
|
45
|
+
"created_user": False,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def proxy_url(port):
|
|
50
|
+
return "http://127.0.0.1:{}".format(int(port))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_root():
|
|
54
|
+
if sys.platform == "win32":
|
|
55
|
+
return False
|
|
56
|
+
return hasattr(os, "geteuid") and os.geteuid() == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _within(path, root):
|
|
60
|
+
path = os.path.abspath(path)
|
|
61
|
+
root = os.path.abspath(root)
|
|
62
|
+
try:
|
|
63
|
+
return os.path.commonpath([path, root]) == root
|
|
64
|
+
except ValueError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_owner():
|
|
69
|
+
"""The installing user. Root uses SUDO_USER, never root's home."""
|
|
70
|
+
if sys.platform == "win32":
|
|
71
|
+
name = os.environ.get("USERNAME") or ""
|
|
72
|
+
home = os.environ.get("USERPROFILE") or ""
|
|
73
|
+
if not name or not home:
|
|
74
|
+
return None
|
|
75
|
+
return {"name": name, "uid": 0, "home": home, "sudo": False}
|
|
76
|
+
import pwd
|
|
77
|
+
if is_root():
|
|
78
|
+
sudo_user = os.environ.get("SUDO_USER") or ""
|
|
79
|
+
if not sudo_user or sudo_user == "root":
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
pw = pwd.getpwnam(sudo_user)
|
|
83
|
+
except KeyError:
|
|
84
|
+
return None
|
|
85
|
+
return {"name": pw.pw_name, "uid": pw.pw_uid, "home": pw.pw_dir, "sudo": True}
|
|
86
|
+
pw = pwd.getpwuid(os.geteuid())
|
|
87
|
+
return {"name": pw.pw_name, "uid": pw.pw_uid, "home": pw.pw_dir, "sudo": False}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def config_base(owner):
|
|
91
|
+
"""XDG config directory of the owner. Root does not fall back to /root."""
|
|
92
|
+
if owner is None:
|
|
93
|
+
return None
|
|
94
|
+
xdg = os.environ.get("XDG_CONFIG_HOME") or ""
|
|
95
|
+
home = owner["home"]
|
|
96
|
+
if owner.get("sudo"):
|
|
97
|
+
if xdg and _within(xdg, home):
|
|
98
|
+
return xdg
|
|
99
|
+
return os.path.join(home, ".config")
|
|
100
|
+
if xdg:
|
|
101
|
+
return xdg
|
|
102
|
+
return os.path.join(home, ".config")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def state_path(owner=None):
|
|
106
|
+
if sys.platform == "win32":
|
|
107
|
+
return os.path.join(WINDOWS_DIR, "state.json")
|
|
108
|
+
if owner is None:
|
|
109
|
+
owner = resolve_owner()
|
|
110
|
+
base = config_base(owner)
|
|
111
|
+
if not base:
|
|
112
|
+
return None
|
|
113
|
+
return os.path.join(base, "agent-lock", "state.json")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def atomic_write(path, text, mode=0o600):
|
|
117
|
+
directory = os.path.dirname(path)
|
|
118
|
+
if directory:
|
|
119
|
+
os.makedirs(directory, exist_ok=True)
|
|
120
|
+
fd, tmp = tempfile.mkstemp(dir=directory or None, prefix=".agent-lock-")
|
|
121
|
+
try:
|
|
122
|
+
data = text.encode("utf-8") if not isinstance(text, bytes) else text
|
|
123
|
+
os.write(fd, data)
|
|
124
|
+
os.close(fd)
|
|
125
|
+
fd = None
|
|
126
|
+
os.chmod(tmp, mode)
|
|
127
|
+
os.replace(tmp, path)
|
|
128
|
+
except Exception:
|
|
129
|
+
if fd is not None:
|
|
130
|
+
os.close(fd)
|
|
131
|
+
try:
|
|
132
|
+
os.unlink(tmp)
|
|
133
|
+
except OSError:
|
|
134
|
+
pass
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def new_state(owner, port, controller, secret, extra_tcp):
|
|
139
|
+
data = dict(STATE_DEFAULTS)
|
|
140
|
+
data["owner"] = owner["name"]
|
|
141
|
+
data["owner_uid"] = owner["uid"]
|
|
142
|
+
data["owner_home"] = owner["home"]
|
|
143
|
+
data["port"] = int(port)
|
|
144
|
+
data["proxy"] = proxy_url(port)
|
|
145
|
+
data["controller"] = controller or ""
|
|
146
|
+
data["secret"] = secret or ""
|
|
147
|
+
data["extra_tcp"] = list(extra_tcp)
|
|
148
|
+
data["claude_settings"] = []
|
|
149
|
+
data["links"] = []
|
|
150
|
+
data["share_dirs"] = []
|
|
151
|
+
data["bin_dirs"] = []
|
|
152
|
+
data["exec_paths"] = []
|
|
153
|
+
data["windows_rules"] = []
|
|
154
|
+
return data
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def apply_install_fields(old, port, extra_tcp, controller, secret):
|
|
158
|
+
"""Port and extra_tcp come from this install. Omitted --allow-tcp clears the list."""
|
|
159
|
+
data = dict(STATE_DEFAULTS)
|
|
160
|
+
if old:
|
|
161
|
+
data.update(old)
|
|
162
|
+
data["version"] = 1
|
|
163
|
+
data["port"] = int(port)
|
|
164
|
+
data["proxy"] = proxy_url(port)
|
|
165
|
+
data["extra_tcp"] = list(extra_tcp)
|
|
166
|
+
if controller is not None:
|
|
167
|
+
data["controller"] = controller
|
|
168
|
+
if secret is not None:
|
|
169
|
+
data["secret"] = secret
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_state(path=None):
|
|
174
|
+
if path is None:
|
|
175
|
+
path = state_path()
|
|
176
|
+
if not path or not os.path.isfile(path):
|
|
177
|
+
return None
|
|
178
|
+
try:
|
|
179
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
180
|
+
data = json.load(handle)
|
|
181
|
+
except (OSError, ValueError) as exc:
|
|
182
|
+
raise AgentLockError("cannot read state: {}".format(exc))
|
|
183
|
+
if not isinstance(data, dict) or data.get("version") != 1:
|
|
184
|
+
raise AgentLockError("state file is not version 1")
|
|
185
|
+
merged = dict(STATE_DEFAULTS)
|
|
186
|
+
merged.update(data)
|
|
187
|
+
if not isinstance(merged.get("extra_tcp"), list):
|
|
188
|
+
merged["extra_tcp"] = []
|
|
189
|
+
if not isinstance(merged.get("claude_settings"), list):
|
|
190
|
+
merged["claude_settings"] = []
|
|
191
|
+
return merged
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def save_state(data, path=None, owner=None):
|
|
195
|
+
if path is None:
|
|
196
|
+
path = state_path()
|
|
197
|
+
if not path:
|
|
198
|
+
raise AgentLockError("no state directory for the installing user; refusing to write as root")
|
|
199
|
+
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
|
200
|
+
atomic_write(path, payload, 0o600)
|
|
201
|
+
_own_state_file(path, data, owner)
|
|
202
|
+
return path
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _own_state_file(path, data, owner):
|
|
206
|
+
"""POSIX: chown back to the installing user. Windows: explicit ACL, not chmod."""
|
|
207
|
+
if sys.platform == "win32":
|
|
208
|
+
name = ""
|
|
209
|
+
if owner:
|
|
210
|
+
name = owner.get("name") or ""
|
|
211
|
+
if not name:
|
|
212
|
+
name = data.get("owner") or ""
|
|
213
|
+
if name:
|
|
214
|
+
from .windows import apply_state_acl
|
|
215
|
+
apply_state_acl(path, name)
|
|
216
|
+
return
|
|
217
|
+
if not is_root():
|
|
218
|
+
return
|
|
219
|
+
uid = owner.get("uid") if owner else None
|
|
220
|
+
if not uid:
|
|
221
|
+
uid = data.get("owner_uid")
|
|
222
|
+
if not uid:
|
|
223
|
+
return
|
|
224
|
+
import pwd
|
|
225
|
+
try:
|
|
226
|
+
gid = pwd.getpwuid(int(uid)).pw_gid
|
|
227
|
+
except KeyError:
|
|
228
|
+
return
|
|
229
|
+
os.chown(path, int(uid), gid)
|
|
230
|
+
directory = os.path.dirname(path)
|
|
231
|
+
if directory:
|
|
232
|
+
try:
|
|
233
|
+
os.chown(directory, int(uid), gid)
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def secret_label(secret):
|
|
239
|
+
return "secret set" if secret else "secret not set"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def merge_claude_settings(document, wrapper_path):
|
|
243
|
+
"""Add env.CLAUDE_CODE_PROCESS_WRAPPER. Do not write proxy variables."""
|
|
244
|
+
if not isinstance(document, dict):
|
|
245
|
+
document = {}
|
|
246
|
+
else:
|
|
247
|
+
document = dict(document)
|
|
248
|
+
env = document.get("env")
|
|
249
|
+
if not isinstance(env, dict):
|
|
250
|
+
env = {}
|
|
251
|
+
else:
|
|
252
|
+
env = dict(env)
|
|
253
|
+
env["CLAUDE_CODE_PROCESS_WRAPPER"] = wrapper_path
|
|
254
|
+
document["env"] = env
|
|
255
|
+
return document
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def strip_claude_settings(document):
|
|
259
|
+
"""Remove only the wrapper key. Return (document, empty)."""
|
|
260
|
+
if not isinstance(document, dict):
|
|
261
|
+
return {}, True
|
|
262
|
+
document = dict(document)
|
|
263
|
+
env = document.get("env")
|
|
264
|
+
if isinstance(env, dict):
|
|
265
|
+
env = dict(env)
|
|
266
|
+
env.pop("CLAUDE_CODE_PROCESS_WRAPPER", None)
|
|
267
|
+
if env:
|
|
268
|
+
document["env"] = env
|
|
269
|
+
else:
|
|
270
|
+
document.pop("env", None)
|
|
271
|
+
empty = document == {}
|
|
272
|
+
return document, empty
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|