agentwheel 0.21.7 → 0.22.1
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.
- package/README.md +63 -0
- package/dist/index.js +19668 -4112
- package/dist/run-as-fs.py +383 -0
- package/dist/run-as-worker.js +13780 -101
- package/openpack.json +1 -1
- package/package.json +1 -1
- package/skills/agentwheel/SKILL.md +1 -1
- package/skills/agentwheel-discovery/SKILL.md +1 -1
|
@@ -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()
|