agentwheel 0.21.7 → 0.22.0

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.
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/python3
2
+ """Bounded, fail-closed Linux filesystem protocol. No command execution.
3
+
4
+ All target resolution uses openat2 below one verified directory descriptor.
5
+ Only single-component mkdir/unlink/rename calls use parent directory descriptors.
6
+ """
7
+ import argparse
8
+ import base64
9
+ import ctypes
10
+ import errno
11
+ import json
12
+ import os
13
+ import platform
14
+ import secrets
15
+ import stat
16
+ import sys
17
+ from contextlib import contextmanager
18
+
19
+ MAX_BYTES = 8 * 1024 * 1024
20
+ MAX_ENTRIES = 4096
21
+ MAX_DEPTH = 64
22
+ MAX_REQUEST = 20 * 1024 * 1024
23
+ RESOLVE = 0x08 | 0x04 | 0x02 # BENEATH | NO_SYMLINKS | NO_MAGICLINKS
24
+ IGNORED = {'.git', 'node_modules', '__pycache__', '.DS_Store'}
25
+
26
+
27
+ class How(ctypes.Structure):
28
+ _fields_ = [('flags', ctypes.c_uint64), ('mode', ctypes.c_uint64),
29
+ ('resolve', ctypes.c_uint64)]
30
+
31
+
32
+ def reject(message):
33
+ raise OSError(errno.EINVAL, message)
34
+
35
+
36
+ def relative(path, allow_root=True):
37
+ if not isinstance(path, str) or '\x00' in path or '\\' in path or path.startswith('/'):
38
+ reject('Expected a contained relative path')
39
+ if path in ('', '.') and allow_root:
40
+ return '.'
41
+ parts = path.split('/')
42
+ if len(parts) > MAX_DEPTH or any(p in ('', '.', '..') for p in parts):
43
+ reject('Invalid contained path or depth limit exceeded')
44
+ path.encode('utf-8', 'strict')
45
+ return path
46
+
47
+
48
+ def decode(content):
49
+ if not isinstance(content, str) or len(content) > ((MAX_BYTES + 2) // 3) * 4:
50
+ reject('File content limit exceeded')
51
+ try:
52
+ value = base64.b64decode(content, validate=True)
53
+ except (ValueError, TypeError):
54
+ reject('Invalid base64 file content')
55
+ if len(value) > MAX_BYTES:
56
+ reject('File content limit exceeded')
57
+ return value
58
+
59
+
60
+ class Filesystem:
61
+ def __init__(self, root, uid, gid):
62
+ if sys.platform != 'linux' or platform.machine() not in ('x86_64', 'aarch64', 'riscv64'):
63
+ raise OSError(errno.ENOSYS, 'Supported Linux openat2 architecture required')
64
+ if uid <= 0 or gid < 0 or (os.getuid(), os.geteuid(), os.getgid(), os.getegid()) != (uid, uid, gid, gid):
65
+ raise OSError(errno.EPERM, 'Pinned non-root UID and GID must match the process')
66
+ if not isinstance(root, str) or not root.startswith('/') or root == '/':
67
+ reject('Expected an absolute non-root directory')
68
+ name = relative(root[1:], False)
69
+ self.libc = ctypes.CDLL(None, use_errno=True)
70
+ self.libc.syscall.restype = ctypes.c_long
71
+ anchor = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
72
+ try:
73
+ self.root = self.open_at(anchor, name, os.O_RDONLY | os.O_DIRECTORY)
74
+ finally:
75
+ os.close(anchor)
76
+ owner = os.fstat(self.root)
77
+ if (owner.st_uid, owner.st_gid) != (uid, gid):
78
+ os.close(self.root)
79
+ raise OSError(errno.EPERM, 'Root directory must have the pinned owner and group')
80
+
81
+ def open_at(self, anchor, path, flags, mode=0):
82
+ how = How(flags | os.O_CLOEXEC, mode, RESOLVE)
83
+ fd = self.libc.syscall(ctypes.c_long(437), ctypes.c_int(anchor),
84
+ ctypes.c_char_p(os.fsencode(path)), ctypes.byref(how), ctypes.sizeof(how))
85
+ if fd < 0:
86
+ code = ctypes.get_errno()
87
+ raise OSError(code, 'Confined openat2 failed: ' + os.strerror(code))
88
+ return fd
89
+
90
+ @contextmanager
91
+ def opened(self, path, flags=os.O_PATH, mode=0):
92
+ fd = self.open_at(self.root, relative(path), flags, mode)
93
+ try:
94
+ self.safe(os.fstat(fd))
95
+ yield fd
96
+ finally:
97
+ os.close(fd)
98
+
99
+ @staticmethod
100
+ def safe(info):
101
+ if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)):
102
+ reject('Unsupported special file or symlink')
103
+ if stat.S_ISREG(info.st_mode) and info.st_nlink != 1:
104
+ reject('Hardlinked regular files are unsupported')
105
+ return info
106
+
107
+ def info(self, path):
108
+ with self.opened(path) as fd:
109
+ return os.fstat(fd)
110
+
111
+ def names(self, path):
112
+ with self.opened(path, os.O_RDONLY | os.O_DIRECTORY) as fd:
113
+ names = []
114
+ with os.scandir(fd) as entries:
115
+ for entry in entries:
116
+ relative(entry.name, False)
117
+ names.append(entry.name)
118
+ if len(names) > MAX_ENTRIES:
119
+ reject('Tree entry limit exceeded')
120
+ return sorted(names)
121
+
122
+ def read(self, path):
123
+ with self.opened(path, os.O_RDONLY | os.O_NONBLOCK) as fd:
124
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
125
+ reject('Expected a regular file')
126
+ chunks, size = [], 0
127
+ while True:
128
+ chunk = os.read(fd, min(65536, MAX_BYTES + 1 - size))
129
+ if not chunk:
130
+ break
131
+ size += len(chunk)
132
+ if size > MAX_BYTES:
133
+ reject('File content limit exceeded')
134
+ chunks.append(chunk)
135
+ self.safe(os.fstat(fd))
136
+ return b''.join(chunks)
137
+
138
+ @staticmethod
139
+ def child(parent, name):
140
+ return name if parent == '.' else parent + '/' + name
141
+
142
+ def snapshot(self, path, include_ignored):
143
+ output, size = [], 0
144
+
145
+ def visit(target, suffix):
146
+ nonlocal size
147
+ if len(output) >= MAX_ENTRIES or (suffix and len(suffix.split('/')) > MAX_DEPTH):
148
+ reject('Tree entry or depth limit exceeded')
149
+ info = self.info(target)
150
+ entry = {'path': suffix, 'kind': 'dir' if stat.S_ISDIR(info.st_mode) else 'file',
151
+ 'mode': stat.S_IMODE(info.st_mode) & (0o7777 if include_ignored else 0o777)}
152
+ output.append(entry)
153
+ if entry['kind'] == 'dir':
154
+ for name in self.names(target):
155
+ if not include_ignored and (name in IGNORED or name.endswith(('.pyc', '.pyo'))):
156
+ continue
157
+ visit(self.child(target, name), name if not suffix else suffix + '/' + name)
158
+ else:
159
+ data = self.read(target)
160
+ size += len(data)
161
+ if size > MAX_BYTES:
162
+ reject('Tree content limit exceeded')
163
+ entry['content'] = base64.b64encode(data).decode('ascii')
164
+ visit(relative(path), '')
165
+ return output
166
+
167
+ @contextmanager
168
+ def parent(self, path, create=False):
169
+ path = relative(path, False)
170
+ parts = path.split('/')
171
+ prefix = '.'
172
+ if create:
173
+ for part in parts[:-1]:
174
+ with self.opened(prefix, os.O_RDONLY | os.O_DIRECTORY) as fd:
175
+ try:
176
+ os.mkdir(part, 0o700, dir_fd=fd)
177
+ except FileExistsError:
178
+ pass
179
+ else:
180
+ # Persist the new inode before publishing its parent entry.
181
+ child = self.child(prefix, part)
182
+ with self.opened(child, os.O_RDONLY | os.O_DIRECTORY) as child_fd:
183
+ os.fchmod(child_fd, 0o700)
184
+ os.fsync(child_fd)
185
+ os.fsync(fd)
186
+ prefix = self.child(prefix, part)
187
+ with self.opened(prefix, os.O_RDONLY | os.O_DIRECTORY):
188
+ pass
189
+ parent = '/'.join(parts[:-1]) or '.'
190
+ with self.opened(parent, os.O_RDONLY | os.O_DIRECTORY) as fd:
191
+ yield fd, parts[-1]
192
+
193
+ def mkdir(self, path):
194
+ with self.parent(path, True) as (fd, name):
195
+ os.mkdir(name, 0o700, dir_fd=fd)
196
+ with self.opened(path, os.O_RDONLY | os.O_DIRECTORY) as child_fd:
197
+ os.fchmod(child_fd, 0o700)
198
+ os.fsync(child_fd)
199
+ os.fsync(fd)
200
+
201
+ def rm(self, path, budget=None):
202
+ path = relative(path, False) # The pinned root cannot be removed.
203
+ if budget is None:
204
+ budget = [0]
205
+ try:
206
+ info = self.info(path)
207
+ except FileNotFoundError:
208
+ return
209
+ budget[0] += 1
210
+ if budget[0] > MAX_ENTRIES:
211
+ reject('Tree entry limit exceeded')
212
+ if stat.S_ISDIR(info.st_mode):
213
+ for name in self.names(path):
214
+ self.rm(self.child(path, name), budget)
215
+ with self.parent(path) as (fd, name):
216
+ os.rmdir(name, dir_fd=fd)
217
+ os.fsync(fd)
218
+ else:
219
+ with self.parent(path) as (fd, name):
220
+ os.unlink(name, dir_fd=fd)
221
+ os.fsync(fd)
222
+
223
+ def write(self, path, data, mode=None):
224
+ path = relative(path, False)
225
+ try:
226
+ info = self.info(path)
227
+ if not stat.S_ISREG(info.st_mode):
228
+ reject('Expected a regular file destination')
229
+ existing_mode = stat.S_IMODE(info.st_mode) & 0o777
230
+ except FileNotFoundError:
231
+ existing_mode = 0o600
232
+ with self.parent(path, True) as (parent, name):
233
+ temp = '.agentwheel-file-' + secrets.token_hex(16)
234
+ fd = self.open_at(parent, temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
235
+ try:
236
+ try:
237
+ offset = 0
238
+ while offset < len(data):
239
+ count = os.write(fd, data[offset:])
240
+ if not count:
241
+ raise OSError(errno.EIO, 'Incomplete file write')
242
+ offset += count
243
+ os.fchmod(fd, existing_mode if mode is None else mode)
244
+ os.fsync(fd)
245
+ finally:
246
+ os.close(fd)
247
+ # A swapped symlink is replaced as a directory entry, never followed.
248
+ os.rename(temp, name, src_dir_fd=parent, dst_dir_fd=parent)
249
+ os.fsync(parent)
250
+ finally:
251
+ try:
252
+ os.unlink(temp, dir_fd=parent)
253
+ os.fsync(parent)
254
+ except FileNotFoundError:
255
+ pass
256
+
257
+ @staticmethod
258
+ def validate_snapshot(entries):
259
+ if not isinstance(entries, list) or not entries or len(entries) > MAX_ENTRIES:
260
+ reject('Invalid snapshot entry count')
261
+ parsed, size = {}, 0
262
+ for entry in entries:
263
+ if not isinstance(entry, dict):
264
+ reject('Invalid snapshot entry')
265
+ path = relative(entry.get('path'))
266
+ if path in parsed or entry.get('kind') not in ('file', 'dir'):
267
+ reject('Duplicate or invalid snapshot entry')
268
+ mode = entry.get('mode')
269
+ if type(mode) is not int or mode < 0 or mode > 0o777:
270
+ reject('Invalid snapshot mode')
271
+ data = decode(entry.get('content')) if entry['kind'] == 'file' else None
272
+ size += len(data) if data is not None else 0
273
+ if size > MAX_BYTES:
274
+ reject('Tree content limit exceeded')
275
+ parsed[path] = (entry['kind'], mode, data)
276
+ if '.' not in parsed:
277
+ reject('Snapshot root is missing')
278
+ for path in parsed:
279
+ if path == '.':
280
+ continue
281
+ parent = path.rsplit('/', 1)[0] if '/' in path else '.'
282
+ if parent not in parsed or parsed[parent][0] != 'dir':
283
+ reject('Snapshot hierarchy is incomplete')
284
+ return parsed
285
+
286
+ def install(self, path, entries):
287
+ path = relative(path, False)
288
+ parsed = self.validate_snapshot(entries) # Validate every byte before mutation.
289
+ with self.parent(path, True) as (parent, name):
290
+ temp = '.agentwheel-tree-' + secrets.token_hex(16)
291
+ prefix = path.rsplit('/', 1)[0] if '/' in path else '.'
292
+ staged = self.child(prefix, temp)
293
+ try:
294
+ if parsed['.'][0] == 'file':
295
+ self.write(staged, parsed['.'][2], parsed['.'][1])
296
+ else:
297
+ self.mkdir(staged)
298
+ for suffix in sorted((p for p in parsed if p != '.'), key=lambda p: (p.count('/'), p)):
299
+ kind, mode, data = parsed[suffix]
300
+ target = self.child(staged, suffix)
301
+ if kind == 'dir':
302
+ self.mkdir(target)
303
+ else:
304
+ self.write(target, data, mode)
305
+ for suffix in sorted(parsed, key=lambda p: -1 if p == '.' else p.count('/'), reverse=True):
306
+ kind, mode, _ = parsed[suffix]
307
+ if kind == 'dir':
308
+ target = staged if suffix == '.' else self.child(staged, suffix)
309
+ with self.opened(target, os.O_RDONLY | os.O_DIRECTORY) as fd:
310
+ os.fchmod(fd, mode)
311
+ os.fsync(fd)
312
+ self.rm(path)
313
+ os.rename(temp, name, src_dir_fd=parent, dst_dir_fd=parent)
314
+ os.fsync(parent)
315
+ finally:
316
+ # Successful rename leaves no staging entry. Permission errors fail closed.
317
+ self.rm(staged)
318
+
319
+ def execute(self, request):
320
+ if not isinstance(request, dict) or type(request.get('version')) is not int or request['version'] != 1:
321
+ reject('Unsupported protocol version')
322
+ operation = request.get('operation')
323
+ if operation == 'probe':
324
+ with self.opened('.', os.O_RDONLY | os.O_DIRECTORY):
325
+ return True
326
+ path = relative(request.get('path'))
327
+ if operation == 'pathExists':
328
+ try:
329
+ self.info(path)
330
+ return True
331
+ except FileNotFoundError:
332
+ return False
333
+ if operation == 'listDir':
334
+ try:
335
+ return self.names(path)
336
+ except FileNotFoundError:
337
+ return []
338
+ if operation == 'readFile':
339
+ return base64.b64encode(self.read(path)).decode('ascii')
340
+ if operation == 'snapshot':
341
+ if type(request.get('includeIgnored')) is not bool:
342
+ reject('includeIgnored must be boolean')
343
+ return self.snapshot(path, request['includeIgnored'])
344
+ if operation == 'mkdirExclusive':
345
+ self.mkdir(path)
346
+ elif operation == 'writeFileAtomic':
347
+ self.write(path, decode(request.get('content')))
348
+ elif operation == 'installSnapshot':
349
+ self.install(path, request.get('entries'))
350
+ elif operation == 'copyWithin':
351
+ self.install(path, self.snapshot(relative(request.get('source')), True))
352
+ elif operation == 'rm':
353
+ self.rm(path)
354
+ else:
355
+ reject('Unsupported filesystem operation')
356
+ return None
357
+
358
+
359
+ def main():
360
+ parser = argparse.ArgumentParser()
361
+ parser.add_argument('--root', required=True)
362
+ parser.add_argument('--uid', required=True, type=int)
363
+ parser.add_argument('--gid', required=True, type=int)
364
+ args = parser.parse_args()
365
+ filesystem = None
366
+ try:
367
+ raw = sys.stdin.buffer.read(MAX_REQUEST + 1)
368
+ if len(raw) > MAX_REQUEST:
369
+ reject('Request size limit exceeded')
370
+ request = json.loads(raw)
371
+ filesystem = Filesystem(args.root, args.uid, args.gid)
372
+ response = {'version': 1, 'result': filesystem.execute(request)}
373
+ except Exception as error:
374
+ code = errno.errorcode.get(error.errno, 'EINVAL') if isinstance(error, OSError) else 'EINVAL'
375
+ response = {'version': 1, 'error': {'message': str(error), 'code': code}}
376
+ finally:
377
+ if filesystem is not None:
378
+ os.close(filesystem.root)
379
+ print(json.dumps(response, separators=(',', ':')))
380
+
381
+
382
+ if __name__ == '__main__':
383
+ main()