@andespindola/crow-skills 0.40.0 → 0.41.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,732 @@
1
+ #!/usr/bin/env python3
2
+ """Explicit isolated upstream runtimes; stdlib manager, NOT a security sandbox.
3
+
4
+ status never executes third-party code. setup requires --yes; run never installs.
5
+ All upstream arguments after -- pass unchanged. Native TUI needs --interactive.
6
+ Private stdout artifacts retain complete upstream output (possibly sensitive).
7
+ Operational reports never include argv, environment, raw stdout or raw stderr.
8
+ """
9
+ import argparse
10
+ import base64
11
+ import contextlib
12
+ import functools
13
+ import hashlib
14
+ import json
15
+ import math
16
+ import os
17
+ from pathlib import Path, PurePosixPath
18
+ import selectors
19
+ import signal
20
+ import stat
21
+ import subprocess
22
+ import sys
23
+ import tarfile
24
+ import time
25
+ import threading
26
+ import urllib.request
27
+ import uuid
28
+
29
+ SCHEMA = 'crow-maker-runtime/1'
30
+ MAX_ARCHIVE = 64 * 1024 * 1024
31
+ MAX_EXTRACTED = 256 * 1024 * 1024
32
+ MAX_OUTPUT = 64 * 1024 * 1024
33
+ SPECS = {
34
+ 'skill-scanner': {
35
+ 'kind': 'python', 'repository': 'cisco-ai-defense/skill-scanner',
36
+ 'extras': ['google', 'bedrock', 'vertex', 'azure', 'all'],
37
+ 'commit': 'c558c421157c546ec417cf23778ce81a3a8f35fd',
38
+ 'url': 'https://api.github.com/repos/cisco-ai-defense/skill-scanner/tarball/c558c421157c546ec417cf23778ce81a3a8f35fd',
39
+ 'sha256': 'aa747556f5179aa213f49c897549052c8b13f0744b78b335921e377774ed3e5f',
40
+ 'distribution': 'cisco-ai-skill-scanner', 'version': '0.0.0+git.c558c421157c',
41
+ 'versionMeaning': 'Synthetic source version, not an upstream release version.',
42
+ 'entrypoints': {'skill-scanner': 'venv/bin/skill-scanner',
43
+ 'skill-scanner-api': 'venv/bin/skill-scanner-api',
44
+ 'skill-scanner-pre-commit': 'venv/bin/skill-scanner-pre-commit'}},
45
+ 'mcp-scanner': {
46
+ 'kind': 'python', 'repository': 'cisco-ai-defense/mcp-scanner',
47
+ 'extras': ['js', 'go', 'java', 'kotlin', 'csharp', 'ruby', 'rust', 'php', 'all-languages'],
48
+ 'commit': '9ff8fc84b792aae315a838cf4e37b40ef46a0293',
49
+ 'url': 'https://api.github.com/repos/cisco-ai-defense/mcp-scanner/tarball/9ff8fc84b792aae315a838cf4e37b40ef46a0293',
50
+ 'sha256': '8f32cf61afbb74f9d60292f1e059fd79eeaefd99572a7bb9624471ec2c252aaf',
51
+ 'distribution': 'cisco-ai-mcp-scanner', 'version': '4.8.7',
52
+ 'entrypoints': {'mcp-scanner': 'venv/bin/mcp-scanner', 'mcp-scanner-api': 'venv/bin/mcp-scanner-api'}},
53
+ 'inspector': {
54
+ 'kind': 'npm', 'repository': 'modelcontextprotocol/inspector', 'extras': [],
55
+ 'commit': '488c30d9d16e4993bc548ad298eb7450f5125c93',
56
+ 'sourceArchiveSha256': '2f3b60cd98e500d2934581ffe7c81c4588e02c3d2742bee6d34c1b5d415500fe',
57
+ 'url': 'https://registry.npmjs.org/@modelcontextprotocol/inspector/-/inspector-2.15.0.tgz',
58
+ 'integrity': 'sha512-TWdX2oJNVTan3B0/0YLpVJWbeuuii3cgDnc7uIwAiCQ9q7QMvZuQrapkQXOC2MKsmyNWj0tplWjza3IolBU+wA==',
59
+ 'distribution': '@modelcontextprotocol/inspector', 'version': '2.15.0',
60
+ 'entrypoints': {'inspector': 'npm/node_modules/@modelcontextprotocol/inspector/clients/launcher/build/index.js',
61
+ 'mcp-inspector': 'npm/node_modules/@modelcontextprotocol/inspector/clients/launcher/build/index.js',
62
+ 'mcpdo': 'npm/node_modules/@modelcontextprotocol/inspector/clients/mcpdo/build/mcp-bin.js'}},
63
+ }
64
+
65
+
66
+ class RuntimeErrorSafe(Exception):
67
+ def __init__(self, code):
68
+ super().__init__(code)
69
+ self.code = code
70
+
71
+
72
+ class OperationCancelled(BaseException):
73
+ """Cancellation crosses setup cleanup before becoming a structured result."""
74
+ def __init__(self, signum):
75
+ self.signum = int(signum)
76
+
77
+
78
+ def _cancel_result(signum, engine=None):
79
+ result = {'schema': SCHEMA, 'state': 'cancelled', 'cancelled': True,
80
+ 'signal': signal.Signals(signum).name, 'signalNumber': signum,
81
+ 'exitCode': 128 + signum, 'timedOut': False, 'ready': False}
82
+ if engine is not None:
83
+ result['engine'] = engine
84
+ return result
85
+
86
+
87
+ class _SignalGuard:
88
+ def __init__(self):
89
+ self.deferred = False
90
+ self.pending = None
91
+
92
+ def handler(self, signum, frame):
93
+ # Do not raise between fork and assignment of the Popen object: that
94
+ # would orphan a newly created process before its owner can clean it.
95
+ if self.deferred:
96
+ self.pending = signum
97
+ else:
98
+ raise OperationCancelled(signum)
99
+
100
+ @contextlib.contextmanager
101
+ def deferred_signals(self):
102
+ self.deferred = True
103
+ try:
104
+ yield
105
+ finally:
106
+ self.deferred = False
107
+ if self.pending is not None:
108
+ signum, self.pending = self.pending, None
109
+ raise OperationCancelled(signum)
110
+
111
+
112
+ @contextlib.contextmanager
113
+ def _termination_scope():
114
+ """Only main-thread operations temporarily own SIGTERM; always restore it.
115
+
116
+ Worker-thread calls cannot replace process signal handlers. Their embedding
117
+ main thread remains responsible for cancellation policy; no global handler
118
+ is installed or leaked by those calls.
119
+ """
120
+ guard = _SignalGuard()
121
+ installed = threading.current_thread() is threading.main_thread()
122
+ previous = signal.getsignal(signal.SIGTERM) if installed else None
123
+ if installed:
124
+ signal.signal(signal.SIGTERM, guard.handler)
125
+ try:
126
+ yield guard
127
+ finally:
128
+ if installed:
129
+ signal.signal(signal.SIGTERM, previous)
130
+
131
+
132
+ def _cancellable(function):
133
+ @functools.wraps(function)
134
+ def wrapped(engine, *args, **kwargs):
135
+ with _termination_scope():
136
+ try:
137
+ return function(engine, *args, **kwargs)
138
+ except OperationCancelled as cancelled:
139
+ return _cancel_result(cancelled.signum, engine)
140
+ except KeyboardInterrupt:
141
+ return _cancel_result(signal.SIGINT, engine)
142
+ return wrapped
143
+
144
+
145
+ def _json(value):
146
+ return json.dumps(value, sort_keys=True, ensure_ascii=True, allow_nan=False).encode()
147
+
148
+
149
+ def _digest(raw):
150
+ return hashlib.sha256(raw).hexdigest()
151
+
152
+
153
+ def _open(path, directory=False):
154
+ """No-follow each absolute component; never accept special-file reads."""
155
+ if not hasattr(os, 'O_NOFOLLOW') or not hasattr(os, 'O_DIRECTORY'):
156
+ raise RuntimeErrorSafe('posix-no-follow-required')
157
+ path = Path(os.path.abspath(path))
158
+ fd = os.open(path.anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
159
+ try:
160
+ for i, part in enumerate(path.parts[1:]):
161
+ final = i == len(path.parts[1:]) - 1
162
+ flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, 'O_NONBLOCK', 0)
163
+ if not final or directory:
164
+ flags |= os.O_DIRECTORY
165
+ new = os.open(part, flags, dir_fd=fd)
166
+ os.close(fd)
167
+ fd = new
168
+ return fd
169
+ except BaseException:
170
+ os.close(fd)
171
+ raise
172
+
173
+
174
+ def _read(path, limit):
175
+ fd = _open(path)
176
+ try:
177
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
178
+ raise RuntimeErrorSafe('regular-file-required')
179
+ chunks, left = [], limit + 1
180
+ while left:
181
+ chunk = os.read(fd, min(65536, left))
182
+ if not chunk:
183
+ break
184
+ chunks.append(chunk)
185
+ left -= len(chunk)
186
+ raw = b''.join(chunks)
187
+ if len(raw) > limit:
188
+ raise RuntimeErrorSafe('file-byte-limit')
189
+ return raw
190
+ finally:
191
+ os.close(fd)
192
+
193
+
194
+ def _private_dir(path, create=False):
195
+ path = Path(os.path.abspath(path))
196
+ if create:
197
+ parent = _open(path.parent, True)
198
+ try:
199
+ try:
200
+ os.mkdir(path.name, 0o700, dir_fd=parent)
201
+ except FileExistsError:
202
+ pass
203
+ finally:
204
+ os.close(parent)
205
+ fd = _open(path, True)
206
+ try:
207
+ info = os.fstat(fd)
208
+ if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
209
+ raise RuntimeErrorSafe('runtime-directory-must-be-owned-and-private')
210
+ finally:
211
+ os.close(fd)
212
+ return path
213
+
214
+
215
+ def _atomic(path, raw):
216
+ fd = _open(Path(path).parent, True)
217
+ temp = '.write-' + uuid.uuid4().hex
218
+ try:
219
+ child = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd)
220
+ with os.fdopen(child, 'wb') as stream:
221
+ stream.write(raw)
222
+ stream.flush()
223
+ os.fsync(stream.fileno())
224
+ os.replace(temp, Path(path).name, src_dir_fd=fd, dst_dir_fd=fd)
225
+ finally:
226
+ try:
227
+ os.unlink(temp, dir_fd=fd)
228
+ except FileNotFoundError:
229
+ pass
230
+ os.close(fd)
231
+
232
+
233
+ def _source_identity(spec):
234
+ if spec['kind'] == 'npm':
235
+ return {'installedDistribution': {key: spec[key] for key in ('distribution', 'version', 'integrity')},
236
+ 'reviewedSource': {key: spec[key] for key in ('repository', 'commit', 'sourceArchiveSha256')},
237
+ 'distributionToReviewedCommitMapping': 'Unverified: registry metadata supplies no gitHead; matching version is not commit proof.'}
238
+ return {key: spec[key] for key in ('repository', 'commit', 'sha256', 'sourceArchiveSha256', 'integrity', 'distribution', 'version') if key in spec}
239
+
240
+
241
+ def _verify_archive(raw, spec):
242
+ if spec['kind'] == 'python':
243
+ valid = _digest(raw) == spec['sha256']
244
+ else:
245
+ valid = 'sha512-' + base64.b64encode(hashlib.sha512(raw).digest()).decode() == spec['integrity']
246
+ if not valid:
247
+ raise RuntimeErrorSafe('archive-hash-mismatch')
248
+
249
+
250
+ def _download(url, timeout=120):
251
+ deadline = time.monotonic() + timeout
252
+ request = urllib.request.Request(url, headers={'User-Agent': 'crow-maker-runtime/1'})
253
+ with urllib.request.urlopen(request, timeout=min(30, timeout)) as response:
254
+ if not response.geturl().startswith('https://'):
255
+ raise RuntimeErrorSafe('insecure-download-redirect')
256
+ chunks, count = [], 0
257
+ while True:
258
+ if time.monotonic() >= deadline:
259
+ raise RuntimeErrorSafe('download-timeout')
260
+ chunk = response.read(65536)
261
+ if not chunk:
262
+ break
263
+ count += len(chunk)
264
+ if count > MAX_ARCHIVE:
265
+ raise RuntimeErrorSafe('archive-byte-limit')
266
+ chunks.append(chunk)
267
+ return b''.join(chunks)
268
+
269
+
270
+ def _extract(archive, destination):
271
+ """Validate entire tar before writes: no links, devices, escapes or duplicates."""
272
+ parent_fd = _open(destination.parent, True)
273
+ try:
274
+ try:
275
+ os.mkdir(destination.name, 0o700, dir_fd=parent_fd)
276
+ except FileExistsError:
277
+ raise RuntimeErrorSafe('extraction-destination-must-be-new') from None
278
+ finally:
279
+ os.close(parent_fd)
280
+ _private_dir(destination)
281
+ with tarfile.open(archive, 'r:gz') as tar:
282
+ members, seen, total, roots = [], set(), 0, set()
283
+ for member in tar:
284
+ path = PurePosixPath(member.name)
285
+ if (not member.name or path.is_absolute() or '..' in path.parts or
286
+ '\\' in member.name or ':' in member.name or
287
+ not (member.isfile() or member.isdir()) or member.name in seen):
288
+ raise RuntimeErrorSafe('unsafe-archive-member')
289
+ if not path.parts or path.parts[0] in ('.', ''):
290
+ raise RuntimeErrorSafe('unsafe-archive-member')
291
+ roots.add(path.parts[0])
292
+ seen.add(member.name)
293
+ total += member.size
294
+ if len(seen) > 30000 or total > MAX_EXTRACTED or member.size > MAX_ARCHIVE:
295
+ raise RuntimeErrorSafe('archive-extraction-limit')
296
+ members.append(member)
297
+ if len(roots) != 1:
298
+ raise RuntimeErrorSafe('archive-must-have-single-source-root')
299
+ for member in members:
300
+ target = destination.joinpath(*PurePosixPath(member.name).parts)
301
+ # Destination was created by us and archive cannot introduce symlinks.
302
+ if member.isdir():
303
+ target.mkdir(mode=0o700, parents=True, exist_ok=True)
304
+ else:
305
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
306
+ descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o700 if member.mode & 0o111 else 0o600)
307
+ with os.fdopen(descriptor, 'wb') as output, tar.extractfile(member) as source:
308
+ left = member.size
309
+ while left:
310
+ chunk = source.read(min(65536, left))
311
+ if not chunk:
312
+ raise RuntimeErrorSafe('archive-truncated-member')
313
+ output.write(chunk)
314
+ left -= len(chunk)
315
+ return destination / next(iter(roots))
316
+
317
+
318
+ def _redact_stderr(raw, argv, environment):
319
+ # Never record argv/environment. Redact known credential values if a tool
320
+ # repeats them on stderr; stdout is an explicitly private raw result artifact.
321
+ candidates = []
322
+ for key, value in environment.items():
323
+ if any(word in key.upper() for word in ('TOKEN', 'SECRET', 'PASSWORD', 'API_KEY', 'AUTHORIZATION')) and len(value) >= 4:
324
+ candidates.append(value)
325
+ for i, value in enumerate(argv):
326
+ if value.startswith('-') and any(word in value.lower() for word in ('token', 'password', 'secret', 'api-key', 'authorization', 'header')):
327
+ if '=' in value:
328
+ candidates.append(value.split('=', 1)[1])
329
+ elif i + 1 < len(argv):
330
+ candidates.append(argv[i + 1])
331
+ for value in sorted(set(candidates), key=len, reverse=True):
332
+ if value:
333
+ raw = raw.replace(value.encode(), b'[REDACTED]')
334
+ return raw
335
+
336
+
337
+ def _kill_group_impl(process):
338
+ try:
339
+ os.killpg(process.pid, signal.SIGTERM)
340
+ except ProcessLookupError:
341
+ return
342
+ try:
343
+ process.wait(timeout=2)
344
+ except subprocess.TimeoutExpired:
345
+ pass
346
+ # Kill the group even if its leader already exited on TERM.
347
+ try:
348
+ os.killpg(process.pid, signal.SIGKILL)
349
+ except ProcessLookupError:
350
+ pass
351
+ process.wait()
352
+
353
+
354
+ def _kill_group(process):
355
+ # A repeated SIGTERM must not interrupt the TERM -> grace -> KILL cleanup
356
+ # and leave a TERM-ignoring child alive. Deliver it only after cleanup.
357
+ with _termination_scope() as guard:
358
+ with guard.deferred_signals():
359
+ _kill_group_impl(process)
360
+
361
+
362
+ def _invoke_impl(argv, home, timeout, environment=None, interactive=False, cwd=None, guard=None):
363
+ if not math.isfinite(timeout) or timeout <= 0 or timeout > 86400:
364
+ raise RuntimeErrorSafe('timeout-must-be-finite-positive-at-most-one-day')
365
+ env = os.environ.copy() if environment is None else environment
366
+ working_directory = home if cwd is None else cwd
367
+ logs = _private_dir(home / 'logs', True)
368
+ stem = uuid.uuid4().hex
369
+ output_path, error_path = logs / (stem + '.stdout'), logs / (stem + '.stderr')
370
+ start = time.monotonic()
371
+ if interactive:
372
+ process = None
373
+ try:
374
+ with guard.deferred_signals():
375
+ process = subprocess.Popen(argv, cwd=working_directory, env=env, shell=False, start_new_session=True)
376
+ code = process.wait(timeout=timeout)
377
+ return {'returnCode': code, 'timedOut': False, 'stdoutRef': None, 'stderrRef': None, 'interactive': True}
378
+ except subprocess.TimeoutExpired:
379
+ return {'returnCode': 124, 'timedOut': True, 'stdoutRef': None, 'stderrRef': None, 'interactive': True}
380
+ finally:
381
+ if process is not None:
382
+ _kill_group(process)
383
+ output_fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
384
+ error_fd = os.open(error_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
385
+ capture = bytearray()
386
+ stderr_chunks = []
387
+ truncated, timed_out, totals = False, False, {'out': 0, 'err': 0}
388
+ process = None
389
+ try:
390
+ with guard.deferred_signals():
391
+ process = subprocess.Popen(argv, cwd=working_directory, env=env, stdin=subprocess.DEVNULL,
392
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
393
+ shell=False, start_new_session=True)
394
+ selector = selectors.DefaultSelector()
395
+ selector.register(process.stdout, selectors.EVENT_READ, 'out')
396
+ selector.register(process.stderr, selectors.EVENT_READ, 'err')
397
+ try:
398
+ while selector.get_map():
399
+ if time.monotonic() - start >= timeout:
400
+ timed_out = True
401
+ _kill_group(process)
402
+ break
403
+ for key, _ in selector.select(timeout=min(.1, timeout)):
404
+ raw = os.read(key.fileobj.fileno(), 65536)
405
+ if not raw:
406
+ selector.unregister(key.fileobj)
407
+ continue
408
+ kind = key.data
409
+ remaining = max(0, MAX_OUTPUT - totals[kind])
410
+ if len(raw) > remaining:
411
+ truncated = True
412
+ kept = raw[:remaining]
413
+ totals[kind] += len(raw)
414
+ if kind == 'out':
415
+ offset = 0
416
+ while offset < len(kept):
417
+ offset += os.write(output_fd, kept[offset:])
418
+ if len(capture) < 8*1024*1024:
419
+ capture.extend(kept[:8*1024*1024-len(capture)])
420
+ else:
421
+ # Stderr chunks are private and known credential values
422
+ # are scrubbed. Arbitrary unknown secrets remain possible.
423
+ stderr_chunks.append(kept)
424
+ if not timed_out:
425
+ try:
426
+ process.wait(timeout=max(.001, timeout - (time.monotonic()-start)))
427
+ except subprocess.TimeoutExpired:
428
+ timed_out = True
429
+ _kill_group(process)
430
+ if not timed_out:
431
+ _kill_group(process)
432
+ scrubbed = _redact_stderr(b''.join(stderr_chunks), argv, env)
433
+ offset = 0
434
+ while offset < len(scrubbed):
435
+ offset += os.write(error_fd, scrubbed[offset:])
436
+ finally:
437
+ selector.close()
438
+ process.stdout.close()
439
+ process.stderr.close()
440
+ except BaseException as failure:
441
+ if process is not None:
442
+ _kill_group(process)
443
+ for stream in (process.stdout, process.stderr):
444
+ if stream is not None and not stream.closed:
445
+ stream.close()
446
+ if isinstance(failure, (OperationCancelled, KeyboardInterrupt)):
447
+ failure.stdout_ref = 'logs/' + output_path.name
448
+ failure.stderr_ref = 'logs/' + error_path.name
449
+ raise
450
+ finally:
451
+ os.close(output_fd)
452
+ os.close(error_fd)
453
+ return {'returnCode': 124 if timed_out else process.returncode, 'timedOut': timed_out,
454
+ 'outputTruncated': truncated, 'stdoutRef': 'logs/' + output_path.name,
455
+ 'stderrRef': 'logs/' + error_path.name, '_stdout': bytes(capture)}
456
+
457
+
458
+ def _invoke(argv, home, timeout, environment=None, interactive=False, cwd=None):
459
+ with _termination_scope() as guard:
460
+ try:
461
+ return _invoke_impl(argv, home, timeout, environment, interactive, cwd, guard)
462
+ except OperationCancelled as cancelled:
463
+ result = _cancel_result(cancelled.signum)
464
+ refs = (getattr(cancelled, 'stdout_ref', None), getattr(cancelled, 'stderr_ref', None))
465
+ except KeyboardInterrupt as cancelled:
466
+ result = _cancel_result(signal.SIGINT)
467
+ refs = (getattr(cancelled, 'stdout_ref', None), getattr(cancelled, 'stderr_ref', None))
468
+ result.update({'returnCode': result['exitCode'], 'stdoutRef': refs[0], 'stderrRef': refs[1],
469
+ 'outputTruncated': False, 'interactive': interactive, '_stdout': b''})
470
+ return result
471
+
472
+
473
+ def _public_result(result):
474
+ return {key: value for key, value in result.items() if not key.startswith('_')}
475
+
476
+
477
+ def _install_env(home):
478
+ env = {key: value for key, value in os.environ.items() if key in ('PATH', 'SYSTEMROOT', 'LANG', 'LC_ALL', 'SSL_CERT_FILE', 'SSL_CERT_DIR')}
479
+ for name in ('home', 'tmp', 'cache'):
480
+ _private_dir(home / name, True)
481
+ # npm rejects loading one path twice as both user and global config.
482
+ # Separate owned empty files also prevent reading the caller's npmrc.
483
+ user_config, global_config = home / 'npm-user.npmrc', home / 'npm-global.npmrc'
484
+ _atomic(user_config, b'')
485
+ _atomic(global_config, b'')
486
+ env.update({'HOME': str(home / 'home'), 'TMPDIR': str(home / 'tmp'),
487
+ 'PIP_CONFIG_FILE': os.devnull, 'PIP_DISABLE_PIP_VERSION_CHECK': '1',
488
+ 'PIP_CACHE_DIR': str(home / 'cache'), 'PYTHONNOUSERSITE': '1',
489
+ 'npm_config_cache': str(home / 'cache'), 'npm_config_userconfig': str(user_config),
490
+ 'npm_config_globalconfig': str(global_config), 'npm_config_audit': 'false',
491
+ 'npm_config_fund': 'false'})
492
+ return env
493
+
494
+
495
+ def _checked(argv, home, timeout, env):
496
+ result = _invoke(argv, home, timeout, environment=env)
497
+ if result.get('cancelled'):
498
+ raise OperationCancelled(result['signalNumber'])
499
+ if result['returnCode'] != 0 or result.get('outputTruncated'):
500
+ raise RuntimeErrorSafe('installer-or-verification-command-failed-see-private-logs')
501
+ return result
502
+
503
+
504
+ def _installed_version(home, spec):
505
+ if spec['kind'] == 'npm':
506
+ data = json.loads(_read(home / 'npm/node_modules/@modelcontextprotocol/inspector/package.json', 1024*1024))
507
+ if data.get('name') != spec['distribution']:
508
+ raise RuntimeErrorSafe('installed-package-name-mismatch')
509
+ return data.get('version')
510
+ site = home / 'venv/lib'
511
+ # Static metadata reads: status does not start Python or import packages.
512
+ versions = []
513
+ for library in site.iterdir():
514
+ if not library.name.startswith('python') or library.is_symlink():
515
+ continue
516
+ packages = library / 'site-packages'
517
+ if not packages.is_dir() or packages.is_symlink():
518
+ continue
519
+ for item in packages.iterdir():
520
+ if item.name.endswith('.dist-info') and not item.is_symlink():
521
+ metadata = _read(item / 'METADATA', 2*1024*1024).decode('utf-8')
522
+ name, version = None, None
523
+ for line in metadata.splitlines():
524
+ if line.startswith('Name: '): name = line[6:].strip()
525
+ if line.startswith('Version: '): version = line[9:].strip()
526
+ if name and version: break
527
+ if name and name.lower().replace('_', '-') == spec['distribution']:
528
+ versions.append(version)
529
+ if len(versions) != 1:
530
+ raise RuntimeErrorSafe('installed-package-metadata-missing-or-ambiguous')
531
+ return versions[0]
532
+
533
+
534
+ def status(engine, runtime_root):
535
+ spec = SPECS[engine]
536
+ root = Path(os.path.abspath(runtime_root))
537
+ result = {'schema': SCHEMA, 'engine': engine, 'installed': False,
538
+ 'configured': False, 'verified': False, 'ready': False,
539
+ 'configurationScope': 'Runtime environment and extras only.',
540
+ 'providerConfiguration': 'not-checked', 'providerCredentialsVerified': False,
541
+ 'source': _source_identity(spec), 'availableExtras': spec['extras'], 'exitCode': 1,
542
+ 'verificationScope': 'Pinned source identity, installed target version, entrypoint hashes and resolved-inventory hash; not all dependency file contents.',
543
+ 'limitations': ['Installed dependencies are resolved and recorded, not fully transitive hash-locked.',
544
+ 'Status does not execute third-party code or validate provider credentials, networking or every upstream mode.']}
545
+ try:
546
+ _private_dir(root)
547
+ home = _private_dir(root / engine)
548
+ result['installed'] = True
549
+ marker = json.loads(_read(home / 'READY.json', 2*1024*1024))
550
+ if (marker.get('schema') != SCHEMA or marker.get('state') != 'complete' or
551
+ marker.get('source') != _source_identity(spec)):
552
+ raise RuntimeErrorSafe('marker-identity-or-state-mismatch')
553
+ extras = marker.get('extras', [])
554
+ if not isinstance(extras, list) or any(value not in spec['extras'] for value in extras):
555
+ raise RuntimeErrorSafe('marker-extras-invalid')
556
+ result['configured'] = True
557
+ result['configuredExtras'] = extras
558
+ if _installed_version(home, spec) != spec['version']:
559
+ raise RuntimeErrorSafe('installed-version-mismatch')
560
+ if _digest(_read(home / 'resolved-inventory.json', 16*1024*1024)) != marker['inventorySha256']:
561
+ raise RuntimeErrorSafe('inventory-changed')
562
+ expected = set(spec['entrypoints'].values())
563
+ if set(marker.get('entrypointSha256', {})) != expected:
564
+ raise RuntimeErrorSafe('entrypoint-set-mismatch')
565
+ for rel, digest in marker['entrypointSha256'].items():
566
+ if _digest(_read(home / rel, 16*1024*1024)) != digest:
567
+ raise RuntimeErrorSafe('entrypoint-changed')
568
+ result.update({'verified': True, 'ready': True, 'exitCode': 0, 'entrypoints': sorted(spec['entrypoints'])})
569
+ if spec['kind'] == 'python':
570
+ result['runtimePython'] = str(home / 'venv/bin/python')
571
+ except (OSError, ValueError, KeyError, TypeError, RuntimeErrorSafe):
572
+ result['reason'] = 'not-installed-partial-or-verification-failed'
573
+ return result
574
+
575
+
576
+ @_cancellable
577
+ def setup(engine, runtime_root, yes=False, archive=None, timeout=1800, python=sys.executable, extras=()):
578
+ if not yes:
579
+ raise RuntimeErrorSafe('setup-requires-explicit-yes')
580
+ if not math.isfinite(timeout) or timeout <= 0 or timeout > 86400:
581
+ raise RuntimeErrorSafe('invalid-setup-timeout')
582
+ spec = SPECS[engine]
583
+ if not isinstance(extras, (list, tuple)) or not all(isinstance(value, str) and value in spec['extras'] for value in extras):
584
+ raise RuntimeErrorSafe('unknown-upstream-extra')
585
+ extras = sorted(set(extras))
586
+ root = _private_dir(runtime_root, True)
587
+ home = root / engine
588
+ if home.exists() or home.is_symlink():
589
+ current = status(engine, root)
590
+ if current['ready']:
591
+ if not set(extras).issubset(current.get('configuredExtras', [])):
592
+ raise RuntimeErrorSafe('additional-extras-require-new-private-runtime-root')
593
+ current['setup'] = 'already-ready-no-install-performed'
594
+ return current
595
+ raise RuntimeErrorSafe('partial-or-existing-runtime-use-new-private-root')
596
+ home = _private_dir(home, True)
597
+ try:
598
+ raw = _read(archive, MAX_ARCHIVE) if archive is not None else _download(spec['url'])
599
+ _verify_archive(raw, spec)
600
+ source_archive = home / 'source.tar.gz'
601
+ _atomic(source_archive, raw)
602
+ source = _extract(source_archive, home / 'source')
603
+ env = _install_env(home)
604
+ if spec['kind'] == 'python':
605
+ env['SETUPTOOLS_SCM_PRETEND_VERSION'] = spec['version']
606
+ _checked([str(python), '-I', '-m', 'venv', str(home / 'venv')], home, timeout, env)
607
+ interpreter = str(home / 'venv/bin/python')
608
+ _checked([interpreter, '-I', '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input',
609
+ '--report', str(home / 'pip-install-report.json'), str(source) + ('[' + ','.join(extras) + ']' if extras else '')], home, timeout, env)
610
+ inventory = _checked([interpreter, '-I', '-m', 'pip', 'list', '--format=json', '--disable-pip-version-check'], home, timeout, env)
611
+ parsed_inventory = json.loads(inventory['_stdout'])
612
+ if not isinstance(parsed_inventory, list):
613
+ raise RuntimeErrorSafe('invalid-resolved-inventory')
614
+ else:
615
+ package = json.loads(_read(source / 'package.json', 1024*1024))
616
+ if package.get('name') != spec['distribution'] or package.get('version') != spec['version']:
617
+ raise RuntimeErrorSafe('archive-package-version-mismatch')
618
+ npm = _private_dir(home / 'npm', True)
619
+ _checked(['npm', '--prefix', str(npm), 'install', '--ignore-scripts', '--no-audit', '--no-fund',
620
+ '--save-exact', str(source_archive)], home, timeout, env)
621
+ inventory = _checked(['npm', '--prefix', str(npm), 'ls', '--json', '--all'], home, timeout, env)
622
+ parsed_inventory = json.loads(inventory['_stdout'])
623
+ if not isinstance(parsed_inventory, dict):
624
+ raise RuntimeErrorSafe('invalid-resolved-inventory')
625
+ if _installed_version(home, spec) != spec['version']:
626
+ raise RuntimeErrorSafe('installed-version-mismatch')
627
+ inventory_raw = _json(parsed_inventory)
628
+ _atomic(home / 'resolved-inventory.json', inventory_raw)
629
+ entries = {rel: _digest(_read(home / rel, 16*1024*1024)) for rel in set(spec['entrypoints'].values())}
630
+ # Only this final atomic write means ready; any preceding failure stays partial.
631
+ marker = {'schema': SCHEMA, 'state': 'complete', 'source': _source_identity(spec),
632
+ 'archiveSha256': _digest(raw), 'extras': extras, 'inventorySha256': _digest(inventory_raw),
633
+ 'entrypointSha256': entries, 'createdAtUnix': time.time(),
634
+ 'dependencyPolicy': 'Normal upstream dependency resolution isolated and inventoried; not a transitive lock guarantee.'}
635
+ _atomic(home / 'READY.json', _json(marker))
636
+ result = status(engine, root)
637
+ if not result['ready']:
638
+ raise RuntimeErrorSafe('post-setup-verification-failed')
639
+ result['setup'] = 'installed-and-verified-within-scope'
640
+ return result
641
+ except BaseException as failure:
642
+ # Retain partial state and private logs, never publish a READY marker.
643
+ try:
644
+ (home / 'READY.json').unlink(missing_ok=True)
645
+ _atomic(home / 'FAILED.json', _json({'schema': SCHEMA, 'state': 'partial-setup', 'source': _source_identity(spec), 'cancelled': isinstance(failure, (OperationCancelled, KeyboardInterrupt))}))
646
+ except OSError:
647
+ pass
648
+ raise
649
+
650
+
651
+ @_cancellable
652
+ def run(engine, runtime_root, args, timeout=300, entrypoint=None, interactive=False, cwd=None):
653
+ current = status(engine, runtime_root)
654
+ if not current['ready']:
655
+ raise RuntimeErrorSafe('engine-not-ready-run-never-installs')
656
+ spec = SPECS[engine]
657
+ entrypoint = entrypoint or engine
658
+ if entrypoint not in spec['entrypoints']:
659
+ raise RuntimeErrorSafe('unknown-engine-entrypoint')
660
+ if not isinstance(args, list) or not all(isinstance(arg, str) and '\x00' not in arg for arg in args):
661
+ raise RuntimeErrorSafe('invalid-upstream-arguments')
662
+ home = Path(os.path.abspath(runtime_root)) / engine
663
+ target = str(home / spec['entrypoints'][entrypoint])
664
+ command = (['node', target] if spec['kind'] == 'npm' else [target]) + args
665
+ working_directory = Path(os.path.abspath(os.getcwd() if cwd is None else cwd))
666
+ directory_fd = _open(working_directory, True)
667
+ os.close(directory_fd)
668
+ result = _invoke(command, home, timeout, interactive=interactive, cwd=working_directory)
669
+ return {'schema': SCHEMA, 'engine': engine, 'entrypoint': entrypoint,
670
+ 'state': 'cancelled' if result.get('cancelled') else 'completed',
671
+ 'cancelled': bool(result.get('cancelled')), 'signal': result.get('signal'),
672
+ 'exitCode': 2 if result.get('outputTruncated') else result['returnCode'],
673
+ 'execution': _public_result(result),
674
+ 'limitations': ['Native upstream execution, not sandboxed; supplied arguments may connect to servers or invoke paid provider APIs.',
675
+ 'Private stdout artifacts retain raw results and may contain sensitive data; do not publish them blindly.',
676
+ 'Stderr scrubbing is best effort for known credential values, not a secret-detection guarantee.',
677
+ 'Timeout cleanup targets the new process group; descendants that deliberately escape it are not contained.']}
678
+
679
+
680
+ def _main_impl(argv=None):
681
+ tokens = list(sys.argv[1:] if argv is None else argv)
682
+ upstream = []
683
+ if '--' in tokens:
684
+ cut = tokens.index('--')
685
+ tokens, upstream = tokens[:cut], tokens[cut+1:]
686
+ parser = argparse.ArgumentParser(description=__doc__)
687
+ parser.add_argument('operation', choices=('status', 'setup', 'run'))
688
+ parser.add_argument('engine', choices=tuple(SPECS))
689
+ parser.add_argument('--runtime-root', required=True)
690
+ parser.add_argument('--yes', action='store_true')
691
+ parser.add_argument('--archive')
692
+ parser.add_argument('--python', default=sys.executable)
693
+ parser.add_argument('--timeout', type=float)
694
+ parser.add_argument('--entrypoint')
695
+ parser.add_argument('--interactive', action='store_true')
696
+ parser.add_argument('--cwd', help='Upstream working directory; defaults to the caller current directory')
697
+ parser.add_argument('--extras', default='', help='Explicit comma-separated declared upstream Python extras')
698
+ args = parser.parse_args(tokens)
699
+ try:
700
+ if upstream and args.operation != 'run':
701
+ raise RuntimeErrorSafe('upstream-arguments-only-valid-for-run')
702
+ if args.operation == 'status':
703
+ result = status(args.engine, args.runtime_root)
704
+ elif args.operation == 'setup':
705
+ result = setup(args.engine, args.runtime_root, args.yes, args.archive,
706
+ args.timeout if args.timeout is not None else 1800, args.python,
707
+ args.extras.split(',') if args.extras else [])
708
+ else:
709
+ result = run(args.engine, args.runtime_root, upstream,
710
+ args.timeout if args.timeout is not None else 300,
711
+ args.entrypoint, args.interactive, args.cwd)
712
+ except (RuntimeErrorSafe, OSError, ValueError, KeyError, TypeError, tarfile.TarError) as error:
713
+ result = {'schema': SCHEMA, 'engine': args.engine, 'exitCode': 2,
714
+ 'error': error.code if isinstance(error, RuntimeErrorSafe) else 'operational-setup-or-runtime-error'}
715
+ print(json.dumps(result, sort_keys=True, ensure_ascii=True, allow_nan=False))
716
+ return result['exitCode']
717
+
718
+
719
+ def main(argv=None):
720
+ with _termination_scope():
721
+ try:
722
+ return _main_impl(argv)
723
+ except OperationCancelled as cancelled:
724
+ result = _cancel_result(cancelled.signum)
725
+ except KeyboardInterrupt:
726
+ result = _cancel_result(signal.SIGINT)
727
+ print(json.dumps(result, sort_keys=True, ensure_ascii=True, allow_nan=False))
728
+ return result['exitCode']
729
+
730
+
731
+ if __name__ == '__main__':
732
+ sys.exit(main())