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