multiagentos 0.4.0__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.
Files changed (122) hide show
  1. agents/__init__.py +1 -0
  2. agents/catalog.py +52 -0
  3. agents/registry.py +11 -0
  4. core/__init__.py +38 -0
  5. core/artifacts.py +47 -0
  6. core/chat_agent_bridge.py +280 -0
  7. core/chat_agent_registry.py +61 -0
  8. core/chat_agent_router.py +85 -0
  9. core/chat_session.py +62 -0
  10. core/contracts/__init__.py +22 -0
  11. core/contracts/agent.py +21 -0
  12. core/contracts/ai.py +25 -0
  13. core/contracts/capability.py +44 -0
  14. core/contracts/chat_agent.py +34 -0
  15. core/contracts/checkpoint.py +78 -0
  16. core/contracts/discovery.py +30 -0
  17. core/contracts/execution.py +28 -0
  18. core/contracts/github.py +74 -0
  19. core/contracts/handoff.py +56 -0
  20. core/contracts/health.py +206 -0
  21. core/contracts/human_review.py +20 -0
  22. core/contracts/ide.py +113 -0
  23. core/contracts/mcp.py +76 -0
  24. core/contracts/model_runtime.py +25 -0
  25. core/contracts/planning.py +25 -0
  26. core/contracts/profile.py +66 -0
  27. core/contracts/quota.py +62 -0
  28. core/contracts/repository.py +34 -0
  29. core/contracts/resume.py +55 -0
  30. core/contracts/runtime.py +19 -0
  31. core/contracts/vyrelon_runtime.py +118 -0
  32. core/contracts/work_unit.py +48 -0
  33. core/delegation.py +33 -0
  34. core/handoff.py +68 -0
  35. core/lifecycle.py +85 -0
  36. core/multi_agent_workflow.py +574 -0
  37. core/orchestrator.py +51 -0
  38. core/planning.py +22 -0
  39. core/registry.py +44 -0
  40. core/routing.py +195 -0
  41. core/state.py +155 -0
  42. installer/__init__.py +1 -0
  43. installer/init.py +71 -0
  44. integrations/__init__.py +0 -0
  45. integrations/github/__init__.py +3 -0
  46. integrations/github/gateway.py +164 -0
  47. integrations/openai/__init__.py +1 -0
  48. integrations/openai/chat_agent.py +147 -0
  49. multiagentos/__init__.py +1 -0
  50. multiagentos/cli.py +759 -0
  51. multiagentos-0.4.0.dist-info/METADATA +7 -0
  52. multiagentos-0.4.0.dist-info/RECORD +122 -0
  53. multiagentos-0.4.0.dist-info/WHEEL +5 -0
  54. multiagentos-0.4.0.dist-info/entry_points.txt +2 -0
  55. multiagentos-0.4.0.dist-info/top_level.txt +7 -0
  56. profiles/__init__.py +1 -0
  57. profiles/common.py +11 -0
  58. profiles/detector.py +67 -0
  59. profiles/registry.py +46 -0
  60. profiles/resolver.py +63 -0
  61. runtime/__init__.py +5 -0
  62. runtime/agent/__init__.py +1 -0
  63. runtime/agent/ide.py +74 -0
  64. runtime/agent/model.py +197 -0
  65. runtime/agent/model_backed.py +49 -0
  66. runtime/agent/process.py +21 -0
  67. runtime/builtin_tools.py +57 -0
  68. runtime/capability.py +143 -0
  69. runtime/chat_adapter_registry.py +43 -0
  70. runtime/chat_config.py +57 -0
  71. runtime/chat_execution.py +52 -0
  72. runtime/config/__init__.py +1 -0
  73. runtime/config/loader.py +67 -0
  74. runtime/discovery.py +334 -0
  75. runtime/execution_config.py +71 -0
  76. runtime/execution_registry.py +50 -0
  77. runtime/git.py +84 -0
  78. runtime/github.py +56 -0
  79. runtime/github_probe.py +23 -0
  80. runtime/health.py +14 -0
  81. runtime/ide/__init__.py +6 -0
  82. runtime/ide/bridge.py +198 -0
  83. runtime/ide/registry.py +24 -0
  84. runtime/ide/runtime.py +35 -0
  85. runtime/local/__init__.py +18 -0
  86. runtime/local/filesystem.py +58 -0
  87. runtime/local/patch.py +79 -0
  88. runtime/local/path_security.py +31 -0
  89. runtime/local/permissions.py +20 -0
  90. runtime/local/process.py +56 -0
  91. runtime/local/shell.py +97 -0
  92. runtime/mcp/__init__.py +7 -0
  93. runtime/mcp/client.py +120 -0
  94. runtime/mcp/config.py +36 -0
  95. runtime/mcp/policy.py +57 -0
  96. runtime/mcp/proxy.py +30 -0
  97. runtime/mcp/server.py +137 -0
  98. runtime/mcp/session.py +19 -0
  99. runtime/model/__init__.py +5 -0
  100. runtime/model/ai_runtime.py +165 -0
  101. runtime/model/cli.py +49 -0
  102. runtime/model/config.py +98 -0
  103. runtime/model/credentials.py +42 -0
  104. runtime/model/factory.py +82 -0
  105. runtime/model/http.py +78 -0
  106. runtime/model/invoker.py +16 -0
  107. runtime/model/materializer.py +101 -0
  108. runtime/model/native.py +244 -0
  109. runtime/model/providers.py +55 -0
  110. runtime/model/registry.py +26 -0
  111. runtime/model/runtime_loader.py +51 -0
  112. runtime/model_control.py +136 -0
  113. runtime/multi_agent.py +142 -0
  114. runtime/policy.py +28 -0
  115. runtime/process.py +41 -0
  116. runtime/quota.py +259 -0
  117. runtime/repository.py +103 -0
  118. runtime/repository_tools.py +62 -0
  119. runtime/status.py +87 -0
  120. runtime/tool_calling.py +134 -0
  121. runtime/validation.py +121 -0
  122. runtime/vyrelon.py +1557 -0
runtime/ide/bridge.py ADDED
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ import json
5
+ from dataclasses import dataclass, fields, is_dataclass
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from threading import Thread
8
+ from typing import Callable
9
+ from urllib.parse import urlparse
10
+
11
+ from runtime.ide.runtime import IDERuntime
12
+ from core.contracts.ide import (
13
+ IDECommand, IDECommandKind, IDEContext, IDEEvent, IDEEventKind, IDEKind, IDEWorkRequest,
14
+ )
15
+
16
+
17
+ class IDEBridgeAuthorizationError(PermissionError):
18
+ pass
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class IDEBridgePolicy:
23
+ token: str
24
+ max_body_bytes: int = 256 * 1024
25
+ allow_remote: bool = False
26
+
27
+ def authorize(self, supplied: str | None) -> None:
28
+ if not self.token or not supplied or not hmac.compare_digest(self.token, supplied):
29
+ raise IDEBridgeAuthorizationError("invalid VYRELON IDE bridge token")
30
+
31
+
32
+ class IDEBridge:
33
+ def __init__(self, *, policy: IDEBridgePolicy,
34
+ runtime: IDERuntime | None = None,
35
+ event_handler: Callable[[IDEEvent], object] | None = None,
36
+ command_handler: Callable[[IDECommand], object] | None = None,
37
+ work_handler: Callable[[IDEWorkRequest], object] | None = None) -> None:
38
+ self.policy = policy
39
+ self.runtime = runtime
40
+ self.event_handler = event_handler or (lambda event: event)
41
+ self.command_handler = command_handler or (lambda command: command)
42
+ self.work_handler = work_handler
43
+
44
+ def handle_event(self, payload: dict[str, object]) -> object:
45
+ event = decode_event(payload)
46
+ if self.runtime is not None:
47
+ return self.runtime.ingest_event(event)
48
+ return self.event_handler(event)
49
+
50
+ def handle_work(self, payload: dict[str, object]) -> object:
51
+ request = decode_work_request(payload)
52
+ if self.work_handler is None:
53
+ raise ValueError("IDE work handler is not configured")
54
+ return self.work_handler(request)
55
+
56
+ def handle_command(self, payload: dict[str, object]) -> object:
57
+ command = decode_command(payload)
58
+ if self.runtime is not None:
59
+ if command.context is None:
60
+ raise ValueError("command context is required for IDE bridge dispatch")
61
+ return self.runtime.execute(command.context.kind, command)
62
+ return self.command_handler(command)
63
+
64
+
65
+ class _Handler(BaseHTTPRequestHandler):
66
+ bridge: IDEBridge
67
+
68
+ def do_POST(self) -> None: # noqa: N802
69
+ if not self.bridge.policy.allow_remote and self.client_address[0] not in {"127.0.0.1", "::1"}:
70
+ self._respond(403, {"error": "loopback only"})
71
+ return
72
+ token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
73
+ try:
74
+ self.bridge.policy.authorize(token)
75
+ length = int(self.headers.get("Content-Length", "0"))
76
+ if length <= 0 or length > self.bridge.policy.max_body_bytes:
77
+ raise ValueError("invalid request body size")
78
+ payload = json.loads(self.rfile.read(length))
79
+ path = urlparse(self.path).path
80
+ if path == "/v1/ide/event":
81
+ result = self.bridge.handle_event(payload)
82
+ elif path == "/v1/ide/work":
83
+ result = self.bridge.handle_work(payload)
84
+ elif path == "/v1/ide/command":
85
+ result = self.bridge.handle_command(payload)
86
+ else:
87
+ self._respond(404, {"error": "not found"})
88
+ return
89
+ self._respond(200, {"ok": True, "result": _jsonable(result)})
90
+ except IDEBridgeAuthorizationError as exc:
91
+ self._respond(401, {"error": str(exc)})
92
+ except (ValueError, TypeError, json.JSONDecodeError) as exc:
93
+ self._respond(400, {"error": str(exc)})
94
+ except Exception as exc: # pragma: no cover
95
+ self._respond(500, {"error": str(exc)})
96
+
97
+ def log_message(self, format: str, *args: object) -> None:
98
+ return
99
+
100
+ def _respond(self, status: int, payload: dict[str, object]) -> None:
101
+ encoded = json.dumps(payload).encode("utf-8")
102
+ self.send_response(status)
103
+ self.send_header("Content-Type", "application/json")
104
+ self.send_header("Content-Length", str(len(encoded)))
105
+ self.end_headers()
106
+ self.wfile.write(encoded)
107
+
108
+
109
+ class IDEBridgeServer:
110
+ def __init__(self, bridge: IDEBridge, host: str = "127.0.0.1", port: int = 8787) -> None:
111
+ if host not in {"127.0.0.1", "::1"} and not bridge.policy.allow_remote:
112
+ raise ValueError("IDE bridge must bind to loopback unless remote access is explicitly allowed")
113
+ self.bridge = bridge
114
+ self.server = ThreadingHTTPServer((host, port), _Handler)
115
+ self.server.RequestHandlerClass.bridge = bridge
116
+ self.thread: Thread | None = None
117
+
118
+ def start(self) -> None:
119
+ if self.thread and self.thread.is_alive():
120
+ return
121
+ self.thread = Thread(target=self.server.serve_forever, daemon=True)
122
+ self.thread.start()
123
+
124
+ def stop(self) -> None:
125
+ self.server.shutdown()
126
+ self.server.server_close()
127
+ if self.thread:
128
+ self.thread.join(timeout=2)
129
+
130
+
131
+ def _jsonable(value: object) -> object:
132
+ if is_dataclass(value):
133
+ return {item.name: _jsonable(getattr(value, item.name)) for item in fields(value)}
134
+ if isinstance(value, (str, int, float, bool)) or value is None:
135
+ return value
136
+ if isinstance(value, dict):
137
+ return {str(key): _jsonable(item) for key, item in value.items()}
138
+ if isinstance(value, (list, tuple)):
139
+ return [_jsonable(item) for item in value]
140
+ return str(value)
141
+
142
+
143
+ def _context(payload: dict[str, object]) -> IDEContext:
144
+ raw = payload.get("context")
145
+ if not isinstance(raw, dict):
146
+ raise ValueError("context must be an object")
147
+ return IDEContext(
148
+ kind=IDEKind(str(raw["kind"])),
149
+ project_root=str(raw.get("project_root", "")),
150
+ workspace_id=raw.get("workspace_id"),
151
+ file_path=raw.get("file_path"),
152
+ selection_start=raw.get("selection_start"),
153
+ selection_end=raw.get("selection_end"),
154
+ language_id=raw.get("language_id"),
155
+ metadata=raw.get("metadata", {}),
156
+ )
157
+
158
+
159
+ def decode_event(payload: dict[str, object]) -> IDEEvent:
160
+ return IDEEvent(
161
+ kind=IDEEventKind(str(payload["kind"])),
162
+ context=_context(payload),
163
+ payload=payload.get("payload", {}),
164
+ metadata=payload.get("metadata", {}),
165
+ )
166
+
167
+
168
+ def decode_command(payload: dict[str, object]) -> IDECommand:
169
+ arguments = payload.get("arguments", {})
170
+ if not isinstance(arguments, dict):
171
+ raise ValueError("arguments must be an object")
172
+ return IDECommand(
173
+ kind=IDECommandKind(str(payload["kind"])),
174
+ arguments=arguments,
175
+ context=_context(payload) if payload.get("context") is not None else None,
176
+ metadata=payload.get("metadata", {}),
177
+ )
178
+
179
+
180
+ def decode_work_request(payload: dict[str, object]) -> IDEWorkRequest:
181
+ objective = payload.get("objective")
182
+ agent_id = payload.get("agent_id")
183
+ model_ids = payload.get("model_ids", [])
184
+ if not isinstance(objective, str) or not objective.strip():
185
+ raise ValueError("objective must be a non-empty string")
186
+ if not isinstance(agent_id, str) or not agent_id.strip():
187
+ raise ValueError("agent_id must be a non-empty string")
188
+ if not isinstance(model_ids, list) or not all(isinstance(item, str) for item in model_ids):
189
+ raise ValueError("model_ids must be a list of strings")
190
+ return IDEWorkRequest(
191
+ context=_context(payload),
192
+ objective=objective,
193
+ agent_id=agent_id,
194
+ model_ids=tuple(model_ids),
195
+ apply_changes=bool(payload.get("apply_changes", False)),
196
+ validation_commands=tuple(payload.get("validation_commands", [])),
197
+ metadata=payload.get("metadata", {}),
198
+ )
@@ -0,0 +1,24 @@
1
+ """Registry for optional IDE adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from core.contracts.ide import IDEAdapter, IDEKind
6
+
7
+
8
+ class IDEAdapterRegistry:
9
+ """Register and resolve IDE adapters without IDE SDK dependencies."""
10
+
11
+ def __init__(self) -> None:
12
+ self._adapters: dict[IDEKind, IDEAdapter] = {}
13
+
14
+ def register(self, adapter: IDEAdapter) -> None:
15
+ self._adapters[adapter.kind] = adapter
16
+
17
+ def get(self, kind: IDEKind) -> IDEAdapter:
18
+ try:
19
+ return self._adapters[kind]
20
+ except KeyError as exc:
21
+ raise LookupError(f"IDE adapter not registered: {kind.value}") from exc
22
+
23
+ def list(self) -> tuple[IDEKind, ...]:
24
+ return tuple(self._adapters)
runtime/ide/runtime.py ADDED
@@ -0,0 +1,35 @@
1
+ """High-level facade for optional IDE adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from core.contracts.ide import IDEAdapter, IDECommand, IDECommandResult, IDEContext, IDEEvent, IDEKind
6
+ from runtime.ide.registry import IDEAdapterRegistry
7
+
8
+
9
+ class IDERuntime:
10
+ """Expose IDE adapters without importing any native IDE SDK."""
11
+
12
+ def __init__(self, registry: IDEAdapterRegistry | None = None) -> None:
13
+ self.registry = registry or IDEAdapterRegistry()
14
+ self._contexts: dict[IDEKind, IDEContext] = {}
15
+
16
+ def register(self, adapter: IDEAdapter) -> None:
17
+ self.registry.register(adapter)
18
+
19
+ def context(self, kind: IDEKind) -> IDEContext:
20
+ if kind in self._contexts:
21
+ return self._contexts[kind]
22
+ context = self.registry.get(kind).context()
23
+ self._contexts[kind] = context
24
+ return context
25
+
26
+ def execute(self, kind: IDEKind, command: IDECommand) -> IDECommandResult:
27
+ return self.registry.get(kind).execute(command)
28
+
29
+ def ingest_event(self, event: IDEEvent) -> object:
30
+ """Record normalized IDE state while keeping event ingestion separate from commands."""
31
+ self._contexts[event.context.kind] = event.context
32
+ return event
33
+
34
+ def capabilities(self, kind: IDEKind) -> frozenset[str]:
35
+ return self.registry.get(kind).capabilities()
@@ -0,0 +1,18 @@
1
+ """VYRELON local tool runtime."""
2
+
3
+ from runtime.local.filesystem import FilesystemRuntime
4
+ from runtime.local.path_security import PathPolicy, PathSecurityError
5
+ from runtime.local.patch import PatchRuntime
6
+ from runtime.local.permissions import LocalPermissionGuard
7
+ from runtime.local.shell import PersistentShellRuntime, ShellResult, ShellState
8
+
9
+ __all__ = [
10
+ "FilesystemRuntime",
11
+ "LocalPermissionGuard",
12
+ "PatchRuntime",
13
+ "PathPolicy",
14
+ "PathSecurityError",
15
+ "PersistentShellRuntime",
16
+ "ShellResult",
17
+ "ShellState",
18
+ ]
@@ -0,0 +1,58 @@
1
+ """Policy- and path-controlled filesystem runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from runtime.local.path_security import PathPolicy
8
+ from runtime.local.permissions import LocalPermissionGuard
9
+ from runtime.policy import ExecutionPolicy
10
+
11
+
12
+ class FilesystemRuntime:
13
+ """First-class VYRELON filesystem operations."""
14
+
15
+ def __init__(
16
+ self,
17
+ policy: ExecutionPolicy | None = None,
18
+ paths: PathPolicy | None = None,
19
+ ) -> None:
20
+ self.policy = policy or ExecutionPolicy()
21
+ self.paths = paths or PathPolicy()
22
+ self.permissions = LocalPermissionGuard(self.policy)
23
+
24
+ def read_text(self, path: str, *, encoding: str = "utf-8") -> str:
25
+ target = self.paths.resolve(path, must_exist=True)
26
+ if not target.is_file():
27
+ raise IsADirectoryError(str(target))
28
+ return target.read_text(encoding=encoding)
29
+
30
+ def write_text(
31
+ self, path: str, content: str, *, encoding: str = "utf-8", approved: bool = False
32
+ ) -> Path:
33
+ self.permissions.require("filesystem.write", approved=approved)
34
+ target = self.paths.resolve(path)
35
+ target.parent.mkdir(parents=True, exist_ok=True)
36
+ target.write_text(content, encoding=encoding)
37
+ return target
38
+
39
+ def list_directory(self, path: str = ".") -> tuple[str, ...]:
40
+ target = self.paths.resolve(path, must_exist=True)
41
+ if not target.is_dir():
42
+ raise NotADirectoryError(str(target))
43
+ return tuple(sorted(item.name for item in target.iterdir()))
44
+
45
+ def create_directory(self, path: str, *, approved: bool = False) -> Path:
46
+ self.permissions.require("filesystem.write", approved=approved)
47
+ target = self.paths.resolve(path)
48
+ target.mkdir(parents=True, exist_ok=True)
49
+ return target
50
+
51
+ def delete(self, path: str, *, approved: bool = False) -> Path:
52
+ self.permissions.require("filesystem.write", approved=approved)
53
+ target = self.paths.resolve(path, must_exist=True)
54
+ if target.is_dir():
55
+ target.rmdir()
56
+ else:
57
+ target.unlink()
58
+ return target
runtime/local/patch.py ADDED
@@ -0,0 +1,79 @@
1
+ """Patch application runtime for VYRELON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from runtime.local.path_security import PathPolicy
9
+ from runtime.local.permissions import LocalPermissionGuard
10
+ from runtime.policy import ExecutionPolicy
11
+ from runtime.process import ProcessResult, ProcessRuntime
12
+
13
+
14
+ class PatchRuntime:
15
+ """Apply/check unified patches through the local Git patch engine.
16
+
17
+ Patch application is a filesystem-write capability. The Git subprocess used
18
+ internally is an implementation detail and must not grant or require the
19
+ separately exposed shell/process capability.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ policy: ExecutionPolicy | None = None,
25
+ paths: PathPolicy | None = None,
26
+ process: ProcessRuntime | None = None,
27
+ ) -> None:
28
+ self.policy = policy or ExecutionPolicy()
29
+ self.paths = paths or PathPolicy()
30
+ self.permissions = LocalPermissionGuard(self.policy)
31
+ self.process = process or ProcessRuntime(
32
+ ExecutionPolicy(
33
+ allow_process=True,
34
+ allow_filesystem_write=self.policy.allow_filesystem_write,
35
+ allow_git_write=self.policy.allow_git_write,
36
+ allow_network=self.policy.allow_network,
37
+ allow_github_write=self.policy.allow_github_write,
38
+ require_approval_for=self.policy.require_approval_for,
39
+ )
40
+ )
41
+
42
+ def _with_patch_file(self, patch_text: str, callback):
43
+ with tempfile.NamedTemporaryFile(
44
+ mode="w",
45
+ encoding="utf-8",
46
+ suffix=".patch",
47
+ delete=True,
48
+ ) as patch_file:
49
+ patch_file.write(patch_text)
50
+ patch_file.flush()
51
+ return callback(patch_file.name)
52
+
53
+ def check(self, project_root: str, patch_text: str) -> ProcessResult:
54
+ root = self.paths.resolve(project_root, must_exist=True)
55
+ if not root.is_dir():
56
+ raise NotADirectoryError(str(root))
57
+ return self._with_patch_file(
58
+ patch_text,
59
+ lambda patch_path: self.process.run(
60
+ ["git", "apply", "--check", patch_path],
61
+ cwd=str(root),
62
+ ),
63
+ )
64
+
65
+ def apply(
66
+ self, project_root: str, patch_text: str, *, approved: bool = False
67
+ ) -> ProcessResult:
68
+ self.permissions.require("filesystem.write", approved=approved)
69
+ checked = self.check(project_root, patch_text)
70
+ if checked.returncode != 0:
71
+ return checked
72
+ root = self.paths.resolve(project_root, must_exist=True)
73
+ return self._with_patch_file(
74
+ patch_text,
75
+ lambda patch_path: self.process.run(
76
+ ["git", "apply", patch_path],
77
+ cwd=str(root),
78
+ ),
79
+ )
@@ -0,0 +1,31 @@
1
+ """Workspace/path security for VYRELON local tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ class PathSecurityError(PermissionError):
9
+ """Raised when a path escapes the configured local workspace."""
10
+
11
+
12
+ class PathPolicy:
13
+ """Resolve paths and optionally confine them to explicit workspace roots."""
14
+
15
+ def __init__(self, roots: tuple[str, ...] = ()) -> None:
16
+ self.roots = tuple(Path(root).expanduser().resolve() for root in roots)
17
+
18
+ def resolve(self, path: str | Path, *, must_exist: bool = False) -> Path:
19
+ candidate = Path(path).expanduser()
20
+ if not candidate.is_absolute() and self.roots:
21
+ candidate = self.roots[0] / candidate
22
+ resolved = candidate.resolve(strict=must_exist)
23
+ self._check_allowed(resolved)
24
+ return resolved
25
+
26
+ def _check_allowed(self, path: Path) -> None:
27
+ if not self.roots:
28
+ return
29
+ if not any(path == root or root in path.parents for root in self.roots):
30
+ allowed = ", ".join(str(root) for root in self.roots)
31
+ raise PathSecurityError(f"path is outside allowed roots: {path}; allowed={allowed}")
@@ -0,0 +1,20 @@
1
+ """Local tool permission enforcement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from runtime.policy import ExecutionPolicy
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class LocalPermissionGuard:
12
+ """Apply VYRELON execution policy to local tool operations."""
13
+
14
+ policy: ExecutionPolicy
15
+
16
+ def require(self, capability: str, *, approved: bool = False) -> None:
17
+ if not self.policy.permits(capability):
18
+ raise PermissionError(f"local capability is disabled: {capability}")
19
+ if self.policy.requires_approval(capability) and not approved:
20
+ raise PermissionError(f"explicit approval required for: {capability}")
@@ -0,0 +1,56 @@
1
+ """Subprocess executor with explicit execution policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from core.contracts.runtime import ExecutionRequest
10
+ from runtime.policy import ExecutionPolicy
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ProcessResult:
15
+ returncode: int
16
+ stdout: str
17
+ stderr: str
18
+ command: tuple[str, ...]
19
+
20
+
21
+ class LocalProcessExecutor:
22
+ """Execute an explicit argv command on the local machine."""
23
+
24
+ def __init__(self, policy: ExecutionPolicy | None = None) -> None:
25
+ self.policy = policy or ExecutionPolicy()
26
+
27
+ def execute(self, request: ExecutionRequest) -> ProcessResult:
28
+ if not self.policy.permits("process"):
29
+ raise PermissionError("Local process execution is disabled by policy")
30
+
31
+ command = request.work_unit.inputs.get("command")
32
+ if not isinstance(command, (list, tuple)) or not command:
33
+ raise ValueError("WorkUnit.inputs['command'] must be a non-empty argv list")
34
+ if not all(isinstance(part, str) and part for part in command):
35
+ raise ValueError("Every command argument must be a non-empty string")
36
+
37
+ cwd_value = request.work_unit.inputs.get("cwd")
38
+ cwd = Path(cwd_value).expanduser() if isinstance(cwd_value, str) else None
39
+ timeout = request.work_unit.inputs.get("timeout", 300)
40
+ if not isinstance(timeout, (int, float)) or timeout <= 0:
41
+ raise ValueError("WorkUnit.inputs['timeout'] must be a positive number")
42
+
43
+ completed = subprocess.run(
44
+ list(command),
45
+ cwd=cwd,
46
+ capture_output=True,
47
+ text=True,
48
+ timeout=timeout,
49
+ check=False,
50
+ )
51
+ return ProcessResult(
52
+ returncode=completed.returncode,
53
+ stdout=completed.stdout,
54
+ stderr=completed.stderr,
55
+ command=tuple(command),
56
+ )
runtime/local/shell.py ADDED
@@ -0,0 +1,97 @@
1
+ """Persistent working-directory shell runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+
10
+ from runtime.local.path_security import PathPolicy
11
+ from runtime.local.permissions import LocalPermissionGuard
12
+ from runtime.policy import ExecutionPolicy
13
+
14
+
15
+ @dataclass
16
+ class ShellState:
17
+ cwd: str
18
+ environment: dict[str, str] = field(default_factory=dict)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ShellResult:
23
+ command: str
24
+ cwd: str
25
+ returncode: int
26
+ stdout: str
27
+ stderr: str
28
+
29
+
30
+ class PersistentShellRuntime:
31
+ """Shell execution with persistent cwd/environment state between calls."""
32
+
33
+ def __init__(
34
+ self,
35
+ cwd: str,
36
+ policy: ExecutionPolicy | None = None,
37
+ paths: PathPolicy | None = None,
38
+ ) -> None:
39
+ self.policy = policy or ExecutionPolicy()
40
+ self.paths = paths or PathPolicy()
41
+ self.permissions = LocalPermissionGuard(self.policy)
42
+ initial = self.paths.resolve(cwd, must_exist=True)
43
+ if not initial.is_dir():
44
+ raise NotADirectoryError(str(initial))
45
+ self.state = ShellState(str(initial))
46
+
47
+ def status(self) -> ShellState:
48
+ return ShellState(self.state.cwd, dict(self.state.environment))
49
+
50
+ def reset(self, cwd: str) -> ShellState:
51
+ self.permissions.require("process")
52
+ target = self.paths.resolve(cwd, must_exist=True)
53
+ if not target.is_dir():
54
+ raise NotADirectoryError(str(target))
55
+ self.state.cwd = str(target)
56
+ return self.status()
57
+
58
+ def set_environment(self, name: str, value: str) -> ShellState:
59
+ self.permissions.require("process")
60
+ if not name or "=" in name or "\x00" in name:
61
+ raise ValueError("invalid environment variable name")
62
+ self.state.environment[name] = value
63
+ return self.status()
64
+
65
+ def run(self, command: str, *, timeout: float = 120.0) -> ShellResult:
66
+ self.permissions.require("process")
67
+ if not command.strip():
68
+ raise ValueError("command must not be empty")
69
+ env = os.environ.copy()
70
+ env.update(self.state.environment)
71
+ completed = subprocess.run(
72
+ command,
73
+ cwd=self.state.cwd,
74
+ env=env,
75
+ shell=True,
76
+ capture_output=True,
77
+ text=True,
78
+ check=False,
79
+ timeout=timeout,
80
+ )
81
+ return ShellResult(
82
+ command=command,
83
+ cwd=self.state.cwd,
84
+ returncode=completed.returncode,
85
+ stdout=completed.stdout,
86
+ stderr=completed.stderr,
87
+ )
88
+
89
+ def cd(self, path: str) -> ShellState:
90
+ target = self.paths.resolve(path, must_exist=True)
91
+ if not target.is_dir():
92
+ raise NotADirectoryError(str(target))
93
+ self.state.cwd = str(target)
94
+ return self.status()
95
+
96
+ def command_words(self, command: str) -> tuple[str, ...]:
97
+ return tuple(shlex.split(command))
@@ -0,0 +1,7 @@
1
+ """MCP runtime for VYRELON."""
2
+ from .client import MCPClient, MCPError, MCPProtocolError
3
+ from .config import MCPConfigLoader
4
+ from .proxy import MCPToolProxy
5
+ from .server import VYRELONMCPServer
6
+ from .session import MCPSessionRegistry
7
+ __all__=["MCPClient","MCPConfigLoader","MCPError","MCPProtocolError","MCPToolProxy","VYRELONMCPServer","MCPSessionRegistry"]