agent-portal-2 0.1.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.
Files changed (120) hide show
  1. package/.continue/agents/new-config.yaml +22 -0
  2. package/AGENT_STEERING.md +36 -0
  3. package/ARCHITECTURE.md +13 -0
  4. package/CHANGELOG.md +97 -0
  5. package/CLI.md +38 -0
  6. package/CONTRIBUTING.md +55 -0
  7. package/INSTALLATION.md +58 -0
  8. package/LICENSE +60 -0
  9. package/PLUGIN_SYSTEM.md +33 -0
  10. package/PYTHON_SDK.md +22 -0
  11. package/QUICKSTART.md +19 -0
  12. package/README.md +385 -0
  13. package/RELEASE_NOTES_v0.1.0.md +281 -0
  14. package/ROADMAP.md +3 -0
  15. package/RUNTIME.md +44 -0
  16. package/SAFETY_MODEL.md +24 -0
  17. package/TESTING.md +35 -0
  18. package/TROUBLESHOOTING.md +30 -0
  19. package/UPGRADE_GUIDE.md +288 -0
  20. package/VS_CODE_EXTENSION.md +47 -0
  21. package/agent-portal.config.json +20 -0
  22. package/apps/desktop/agent-portal-desktop.zip +0 -0
  23. package/apps/desktop/fixtures/local-workflow.html +151 -0
  24. package/apps/desktop/package.json +18 -0
  25. package/apps/desktop/src/main.ts +117 -0
  26. package/apps/desktop/tsconfig.json +8 -0
  27. package/apps/vscode-extension/LICENSE +60 -0
  28. package/apps/vscode-extension/README.md +20 -0
  29. package/apps/vscode-extension/media/agent-portal-logo.png +0 -0
  30. package/apps/vscode-extension/package.json +149 -0
  31. package/apps/vscode-extension/src/extension.ts +614 -0
  32. package/apps/vscode-extension/tsconfig.json +12 -0
  33. package/assets/branding/agent-portal-logo.png +0 -0
  34. package/connectors/chatgpt-tools/README.md +9 -0
  35. package/connectors/claude-mcp-server/README.md +9 -0
  36. package/connectors/gemini-connector/README.md +9 -0
  37. package/connectors/rest-websocket-api/README.md +9 -0
  38. package/docs/MCP_SERVER.md +68 -0
  39. package/docs/architecture.md +214 -0
  40. package/docs/roadmap.md +125 -0
  41. package/package.json +21 -0
  42. package/packages/agent-portal-mcp/README.md +12 -0
  43. package/packages/agent-portal-mcp/agent_portal_mcp/__init__.py +3 -0
  44. package/packages/agent-portal-mcp/agent_portal_mcp/bridge/__init__.py +1 -0
  45. package/packages/agent-portal-mcp/agent_portal_mcp/bridge/runtime_client.py +180 -0
  46. package/packages/agent-portal-mcp/agent_portal_mcp/cli.py +32 -0
  47. package/packages/agent-portal-mcp/agent_portal_mcp/doctor.py +71 -0
  48. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/__init__.py +1 -0
  49. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/actions.py +17 -0
  50. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/results.py +24 -0
  51. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/risk.py +20 -0
  52. package/packages/agent-portal-mcp/agent_portal_mcp/security/__init__.py +1 -0
  53. package/packages/agent-portal-mcp/agent_portal_mcp/security/policy.py +27 -0
  54. package/packages/agent-portal-mcp/agent_portal_mcp/server.py +148 -0
  55. package/packages/agent-portal-mcp/agent_portal_mcp/tool_registry.py +58 -0
  56. package/packages/agent-portal-mcp/agent_portal_mcp/tools/__init__.py +1 -0
  57. package/packages/agent-portal-mcp/agent_portal_mcp/tools/browser.py +89 -0
  58. package/packages/agent-portal-mcp/agent_portal_mcp/tools/common.py +98 -0
  59. package/packages/agent-portal-mcp/agent_portal_mcp/tools/inspection.py +93 -0
  60. package/packages/agent-portal-mcp/agent_portal_mcp/tools/navigation.py +93 -0
  61. package/packages/agent-portal-mcp/agent_portal_mcp/tools/reports.py +34 -0
  62. package/packages/agent-portal-mcp/agent_portal_mcp/tools/steering.py +93 -0
  63. package/packages/agent-portal-mcp/pyproject.toml +20 -0
  64. package/packages/agent-portal-mcp/tests/test_doctor.py +20 -0
  65. package/packages/agent-portal-mcp/tests/test_mcp_server.py +161 -0
  66. package/packages/core/package.json +15 -0
  67. package/packages/core/src/index.ts +1842 -0
  68. package/packages/core/tsconfig.json +8 -0
  69. package/packages/mcp-server/package.json +15 -0
  70. package/packages/mcp-server/src/index.ts +73 -0
  71. package/packages/mcp-server/tsconfig.json +8 -0
  72. package/packages/sdk/package.json +15 -0
  73. package/packages/sdk/src/index.ts +544 -0
  74. package/packages/sdk/tsconfig.json +8 -0
  75. package/plugins/README.md +16 -0
  76. package/plugins/agent-portal-browser/plugin.json +19 -0
  77. package/plugins/agent-portal-python/plugin.json +16 -0
  78. package/plugins/agent-portal-skills/plugin.json +19 -0
  79. package/plugins/agent-portal-vscode/plugin.json +27 -0
  80. package/plugins/example-runtime-plugin/README.md +3 -0
  81. package/plugins/example-runtime-plugin/plugin.json +20 -0
  82. package/plugins/plugin.schema.json +53 -0
  83. package/python/README.md +18 -0
  84. package/python/agent_portal/__init__.py +5 -0
  85. package/python/agent_portal/__main__.py +5 -0
  86. package/python/agent_portal/browser.py +393 -0
  87. package/python/agent_portal/cli.py +164 -0
  88. package/python/agent_portal/config.py +31 -0
  89. package/python/agent_portal/doctor.py +165 -0
  90. package/python/agent_portal/exceptions.py +39 -0
  91. package/python/agent_portal/logging_utils.py +33 -0
  92. package/python/agent_portal/metrics.py +309 -0
  93. package/python/agent_portal/models.py +160 -0
  94. package/python/agent_portal/plugin_system.py +42 -0
  95. package/python/agent_portal/rate_limit.py +253 -0
  96. package/python/agent_portal/runtime.py +739 -0
  97. package/python/agent_portal/server.py +351 -0
  98. package/python/agent_portal/validation.py +299 -0
  99. package/python/pyproject.toml +29 -0
  100. package/python/tests/test_config.py +24 -0
  101. package/python/tests/test_doctor.py +19 -0
  102. package/python/tests/test_metrics.py +180 -0
  103. package/python/tests/test_rate_limit.py +237 -0
  104. package/python/tests/test_runtime.py +122 -0
  105. package/python/tests/test_server.py +53 -0
  106. package/python/tests/test_validation.py +170 -0
  107. package/releases/desktop/agent-portal-desktop/README.md +378 -0
  108. package/releases/desktop/agent-portal-desktop/RELEASE_NOTES.md +14 -0
  109. package/releases/desktop/agent-portal-desktop/assets/branding/agent-portal-logo.png +0 -0
  110. package/releases/desktop/agent-portal-desktop/fixtures/local-workflow.html +151 -0
  111. package/releases/desktop/agent-portal-desktop/launch-agent-portal.bat +4 -0
  112. package/releases/desktop/agent-portal-desktop.zip +0 -0
  113. package/releases/python/agent_portal-0.0.2-py3-none-any.whl +0 -0
  114. package/releases/python/agent_portal-0.0.2.tar.gz +0 -0
  115. package/scripts/package_desktop.mjs +117 -0
  116. package/scripts/release_python.py +46 -0
  117. package/tests/plugin-manifest.test.mjs +26 -0
  118. package/tests/runtime.test.mjs +41 -0
  119. package/tests/vscode-extension.test.mjs +22 -0
  120. package/tsconfig.base.json +16 -0
@@ -0,0 +1,351 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from dataclasses import asdict
6
+ from http import HTTPStatus
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ from .exceptions import AgentPortalError
12
+ from .models import ActionRequest
13
+ from .runtime import PortalRuntime
14
+
15
+
16
+ def build_server(runtime: PortalRuntime) -> HTTPServer:
17
+ class AgentPortalHandler(BaseHTTPRequestHandler):
18
+ server_version = "AgentPortalRuntime/0.0.3"
19
+
20
+ def _send_json(self, status: int, payload: object) -> None:
21
+ encoded = json.dumps(payload, indent=2).encode("utf8")
22
+ self.send_response(status)
23
+ self.send_header("Content-Type", "application/json")
24
+ self.send_header("Content-Length", str(len(encoded)))
25
+ self.send_header("Access-Control-Allow-Origin", "*")
26
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
27
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
28
+ self.end_headers()
29
+ self.wfile.write(encoded)
30
+
31
+ def _read_json(self) -> dict[str, object]:
32
+ content_length = int(self.headers.get("Content-Length", "0"))
33
+ if content_length == 0:
34
+ return {}
35
+ return json.loads(self.rfile.read(content_length).decode("utf8"))
36
+
37
+ def _authorize(self) -> bool:
38
+ token = runtime.config.api_token
39
+ if not token:
40
+ return True
41
+ header = self.headers.get("Authorization", "")
42
+ return header == f"Bearer {token}"
43
+
44
+ def _unauthorized(self) -> None:
45
+ self._send_json(
46
+ HTTPStatus.UNAUTHORIZED,
47
+ {
48
+ "error": "Unauthorized",
49
+ "suggestedFix": "Provide the configured runtime API token as a Bearer token.",
50
+ },
51
+ )
52
+
53
+ def do_OPTIONS(self) -> None: # noqa: N802
54
+ self.send_response(HTTPStatus.NO_CONTENT)
55
+ self.send_header("Access-Control-Allow-Origin", "*")
56
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
57
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
58
+ self.end_headers()
59
+
60
+ def do_GET(self) -> None: # noqa: N802
61
+ parsed = urlparse(self.path)
62
+ if parsed.path != "/health" and not self._authorize():
63
+ self._unauthorized()
64
+ return
65
+ if parsed.path == "/health":
66
+ self._send_json(HTTPStatus.OK, runtime.health())
67
+ return
68
+ if parsed.path == "/status":
69
+ self._send_json(HTTPStatus.OK, runtime.status())
70
+ return
71
+ if parsed.path == "/report/latest":
72
+ report_path = runtime.generate_report()
73
+ self._send_json(
74
+ HTTPStatus.OK,
75
+ {
76
+ "reportPath": str(report_path),
77
+ "report": json.loads(report_path.read_text(encoding="utf8")),
78
+ },
79
+ )
80
+ return
81
+ self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown path"})
82
+
83
+ def do_POST(self) -> None: # noqa: N802
84
+ parsed = urlparse(self.path)
85
+ if not self._authorize():
86
+ self._unauthorized()
87
+ return
88
+ payload = self._read_json()
89
+ try:
90
+ if parsed.path == "/control/start":
91
+ runtime.start()
92
+ self._send_json(HTTPStatus.OK, runtime.status())
93
+ return
94
+ if parsed.path == "/control/stop":
95
+ self._send_json(HTTPStatus.OK, {"status": "stopping"})
96
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
97
+ runtime.stop()
98
+ return
99
+ if parsed.path == "/control/pause":
100
+ runtime.pause()
101
+ self._send_json(HTTPStatus.OK, runtime.status())
102
+ return
103
+ if parsed.path == "/control/resume":
104
+ runtime.resume()
105
+ self._send_json(HTTPStatus.OK, runtime.status())
106
+ return
107
+ if parsed.path == "/control/restart":
108
+ runtime.restart()
109
+ self._send_json(HTTPStatus.OK, runtime.status())
110
+ return
111
+ if parsed.path == "/control/goal":
112
+ runtime.set_goal(str(payload.get("goal", "")))
113
+ self._send_json(HTTPStatus.OK, runtime.status())
114
+ return
115
+ if parsed.path == "/control/goal/current":
116
+ self._send_json(
117
+ HTTPStatus.OK,
118
+ {"goal": runtime.get_current_goal()},
119
+ )
120
+ return
121
+ if parsed.path == "/control/action-mode":
122
+ runtime.set_action_mode(str(payload.get("mode", "")))
123
+ self._send_json(HTTPStatus.OK, runtime.status())
124
+ return
125
+ if parsed.path == "/control/action-queue":
126
+ self._send_json(HTTPStatus.OK, {"actions": runtime.get_action_queue()})
127
+ return
128
+ if parsed.path == "/control/propose-action":
129
+ action = runtime.propose_action(
130
+ ActionRequest(
131
+ action_type=str(payload.get("actionType", "")),
132
+ reason=str(payload.get("reason", "")),
133
+ target=str(payload["target"]) if "target" in payload and payload["target"] is not None else None,
134
+ payload=str(payload["payload"]) if "payload" in payload and payload["payload"] is not None else None,
135
+ risk_level=str(payload.get("riskLevel", "low")),
136
+ )
137
+ )
138
+ self._send_json(HTTPStatus.OK, asdict(action))
139
+ return
140
+ if parsed.path == "/control/approve-action":
141
+ action_id = str(payload.get("actionId", ""))
142
+ execute_now = bool(payload.get("execute", False))
143
+ result = runtime.execute_action(action_id) if execute_now else runtime.approve_action(action_id)
144
+ self._send_json(HTTPStatus.OK, asdict(result))
145
+ return
146
+ if parsed.path == "/control/reject-action":
147
+ result = runtime.reject_action(
148
+ str(payload.get("actionId", "")),
149
+ str(payload.get("reason", "Rejected by user")),
150
+ )
151
+ self._send_json(HTTPStatus.OK, asdict(result))
152
+ return
153
+ if parsed.path == "/browser/start":
154
+ runtime.ensure_browser()
155
+ self._send_json(HTTPStatus.OK, runtime.status())
156
+ return
157
+ if parsed.path == "/browser/close":
158
+ self._send_json(HTTPStatus.OK, asdict(runtime.close_browser()))
159
+ return
160
+ if parsed.path == "/browser/refresh":
161
+ self._send_json(HTTPStatus.OK, asdict(runtime.refresh()))
162
+ return
163
+ if parsed.path == "/browser/back":
164
+ self._send_json(HTTPStatus.OK, asdict(runtime.back()))
165
+ return
166
+ if parsed.path == "/browser/forward":
167
+ self._send_json(HTTPStatus.OK, asdict(runtime.forward()))
168
+ return
169
+ if parsed.path == "/browser/status":
170
+ self._send_json(HTTPStatus.OK, runtime.browser_status())
171
+ return
172
+ if parsed.path == "/browser/open":
173
+ self._send_json(
174
+ HTTPStatus.OK,
175
+ asdict(runtime.open_url(str(payload.get("url", "")))),
176
+ )
177
+ return
178
+ if parsed.path == "/browser/click":
179
+ self._send_json(
180
+ HTTPStatus.OK,
181
+ asdict(
182
+ runtime.click(
183
+ str(payload.get("selector", "")),
184
+ str(payload.get("reason", "Click element")),
185
+ )
186
+ ),
187
+ )
188
+ return
189
+ if parsed.path == "/browser/type":
190
+ self._send_json(
191
+ HTTPStatus.OK,
192
+ asdict(
193
+ runtime.type_text(
194
+ str(payload.get("selector", "")),
195
+ str(payload.get("value", "")),
196
+ str(payload.get("reason", "Type into element")),
197
+ )
198
+ ),
199
+ )
200
+ return
201
+ if parsed.path == "/browser/scroll":
202
+ self._send_json(
203
+ HTTPStatus.OK,
204
+ asdict(
205
+ runtime.scroll(
206
+ str(payload["selector"]) if "selector" in payload and payload["selector"] is not None else None,
207
+ str(payload.get("reason", "Scroll page")),
208
+ )
209
+ ),
210
+ )
211
+ return
212
+ if parsed.path == "/browser/hover":
213
+ self._send_json(
214
+ HTTPStatus.OK,
215
+ asdict(
216
+ runtime.hover(
217
+ str(payload.get("selector", "")),
218
+ str(payload.get("reason", "Hover over element")),
219
+ )
220
+ ),
221
+ )
222
+ return
223
+ if parsed.path == "/browser/wait":
224
+ self._send_json(
225
+ HTTPStatus.OK,
226
+ asdict(
227
+ runtime.wait(
228
+ str(payload.get("selector", "")),
229
+ str(payload.get("reason", "Wait for element")),
230
+ )
231
+ ),
232
+ )
233
+ return
234
+ if parsed.path == "/browser/screenshot":
235
+ self._send_json(
236
+ HTTPStatus.OK,
237
+ asdict(runtime.screenshot(str(payload.get("label", "manual")))),
238
+ )
239
+ return
240
+ if parsed.path == "/browser/capture":
241
+ self._send_json(
242
+ HTTPStatus.OK,
243
+ runtime.capture_snapshot(str(payload.get("label", "capture"))),
244
+ )
245
+ return
246
+ if parsed.path == "/browser/inspect":
247
+ self._send_json(HTTPStatus.OK, runtime.inspect())
248
+ return
249
+ if parsed.path == "/browser/read-dom":
250
+ self._send_json(HTTPStatus.OK, runtime.read_dom())
251
+ return
252
+ if parsed.path == "/browser/read-accessibility-tree":
253
+ self._send_json(HTTPStatus.OK, runtime.read_accessibility_tree())
254
+ return
255
+ if parsed.path == "/browser/read-console-errors":
256
+ self._send_json(HTTPStatus.OK, runtime.read_console_errors())
257
+ return
258
+ if parsed.path == "/browser/read-network-failures":
259
+ self._send_json(HTTPStatus.OK, runtime.read_network_failures())
260
+ return
261
+ if parsed.path == "/browser/read-text":
262
+ self._send_json(
263
+ HTTPStatus.OK,
264
+ runtime.read_text(
265
+ str(payload.get("selector", "")),
266
+ str(payload.get("reason", "Read text from element")),
267
+ ),
268
+ )
269
+ return
270
+ if parsed.path == "/browser/execute":
271
+ self._send_json(
272
+ HTTPStatus.OK,
273
+ runtime.execute(
274
+ str(payload.get("script", "")),
275
+ str(payload.get("reason", "Execute script in page context")),
276
+ ),
277
+ )
278
+ return
279
+ if parsed.path == "/browser/inspect-element":
280
+ self._send_json(
281
+ HTTPStatus.OK,
282
+ runtime.inspect_element(str(payload.get("selector", ""))),
283
+ )
284
+ return
285
+ if parsed.path == "/control/approve-next":
286
+ if runtime.session.pending_actions:
287
+ result = runtime.approve_action(runtime.session.pending_actions[0].action_id)
288
+ self._send_json(HTTPStatus.OK, asdict(result))
289
+ return
290
+ self._send_json(HTTPStatus.OK, {"message": "No pending action"})
291
+ return
292
+ if parsed.path == "/control/reject-next":
293
+ if runtime.session.pending_actions:
294
+ result = runtime.reject_action(
295
+ runtime.session.pending_actions[0].action_id,
296
+ str(payload.get("reason", "Rejected by user")),
297
+ )
298
+ self._send_json(HTTPStatus.OK, asdict(result))
299
+ return
300
+ self._send_json(HTTPStatus.OK, {"message": "No pending action"})
301
+ return
302
+ if parsed.path == "/report/generate":
303
+ report_path = runtime.generate_report()
304
+ self._send_json(HTTPStatus.OK, {"reportPath": str(report_path)})
305
+ return
306
+ if parsed.path == "/report/list":
307
+ self._send_json(HTTPStatus.OK, {"reports": runtime.list_reports()})
308
+ return
309
+ if parsed.path == "/report/read":
310
+ self._send_json(
311
+ HTTPStatus.OK,
312
+ runtime.read_report(str(payload.get("report", ""))),
313
+ )
314
+ return
315
+ if parsed.path == "/report/export":
316
+ self._send_json(
317
+ HTTPStatus.OK,
318
+ runtime.export_report(
319
+ str(payload.get("report", "")),
320
+ str(payload["destination"]) if "destination" in payload and payload["destination"] is not None else None,
321
+ ),
322
+ )
323
+ return
324
+ self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown path"})
325
+ except AgentPortalError as exc:
326
+ self._send_json(HTTPStatus.BAD_REQUEST, exc.to_dict())
327
+
328
+ def log_message(self, format: str, *args: object) -> None: # noqa: A003
329
+ return
330
+
331
+ server = HTTPServer(
332
+ (runtime.config.runtime_host, runtime.config.runtime_port),
333
+ AgentPortalHandler,
334
+ )
335
+ server.allow_reuse_address = True
336
+ return server
337
+
338
+
339
+ def serve(runtime: PortalRuntime | None = None) -> None:
340
+ active_runtime = runtime or PortalRuntime(Path.cwd())
341
+ active_runtime.start()
342
+ server = build_server(active_runtime)
343
+ try:
344
+ server.serve_forever()
345
+ finally:
346
+ server.server_close()
347
+ active_runtime.stop()
348
+
349
+
350
+ def main() -> None:
351
+ serve()
@@ -0,0 +1,299 @@
1
+ """Input validation and sanitization utilities for Agent Portal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ValidationResult:
13
+ """Result of a validation check."""
14
+ is_valid: bool
15
+ errors: list[str]
16
+
17
+
18
+ class ValidationError(Exception):
19
+ """Raised when validation fails."""
20
+ pass
21
+
22
+
23
+ # URL validation patterns
24
+ SAFE_PROTOCOLS = {"http", "https"}
25
+ DOMAIN_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$")
26
+ LOCALHOST_PATTERNS = {
27
+ "localhost",
28
+ "127.0.0.1",
29
+ "0.0.0.0",
30
+ "::1",
31
+ }
32
+
33
+
34
+ def validate_url(url: str, allow_local: bool = True) -> ValidationResult:
35
+ """
36
+ Validate a URL for safety and structure.
37
+
38
+ Args:
39
+ url: The URL to validate
40
+ allow_local: Whether to allow localhost/local network addresses
41
+
42
+ Returns:
43
+ ValidationResult indicating if the URL is valid
44
+ """
45
+ errors: list[str] = []
46
+
47
+ if not url or not isinstance(url, str):
48
+ errors.append("URL must be a non-empty string")
49
+ return ValidationResult(False, errors)
50
+
51
+ # Check for dangerous patterns first (before parsing)
52
+ url_lower = url.lower()
53
+ if any(pattern in url_lower for pattern in ["javascript:", "data:", "file:", "ftp:", "vbscript:"]):
54
+ errors.append("Potentially dangerous URL pattern detected")
55
+ return ValidationResult(False, errors)
56
+
57
+ try:
58
+ parsed = urlparse(url)
59
+ except Exception as exc:
60
+ errors.append(f"Invalid URL format: {exc}")
61
+ return ValidationResult(False, errors)
62
+
63
+ if not parsed.scheme:
64
+ errors.append("URL must include a protocol (http:// or https://)")
65
+ elif parsed.scheme not in SAFE_PROTOCOLS:
66
+ errors.append(f"Unsupported protocol: {parsed.scheme}. Only http:// and https:// are allowed")
67
+
68
+ if not parsed.netloc:
69
+ errors.append("URL must include a hostname")
70
+ return ValidationResult(False, errors)
71
+
72
+ # Check for localhost if not explicitly allowed
73
+ if not allow_local:
74
+ if parsed.hostname in LOCALHOST_PATTERNS or parsed.hostname == "0.0.0.0":
75
+ errors.append("Localhost addresses are not allowed in this mode")
76
+
77
+ return ValidationResult(len(errors) == 0, errors)
78
+
79
+
80
+ def validate_selector(selector: str | None) -> ValidationResult:
81
+ """
82
+ Validate a CSS selector for injection safety.
83
+
84
+ Args:
85
+ selector: The CSS selector to validate
86
+
87
+ Returns:
88
+ ValidationResult indicating if the selector is safe
89
+ """
90
+ errors: list[str] = []
91
+
92
+ if not selector:
93
+ return ValidationResult(True, errors)
94
+
95
+ if not isinstance(selector, str):
96
+ errors.append("Selector must be a string")
97
+ return ValidationResult(False, errors)
98
+
99
+ if len(selector) > 1000:
100
+ errors.append("Selector is too long (max 1000 characters)")
101
+
102
+ # Check for potential XSS patterns
103
+ xss_patterns = [
104
+ "<script",
105
+ "javascript:",
106
+ "onerror=",
107
+ "onload=",
108
+ "onclick=",
109
+ "fromCharCode",
110
+ "eval(",
111
+ ]
112
+
113
+ selector_lower = selector.lower()
114
+ if any(pattern in selector_lower for pattern in xss_patterns):
115
+ errors.append("Selector contains potentially dangerous patterns")
116
+
117
+ return ValidationResult(len(errors) == 0, errors)
118
+
119
+
120
+ def validate_script(script: str) -> ValidationResult:
121
+ """
122
+ Validate a JavaScript script for basic safety.
123
+
124
+ Args:
125
+ script: The JavaScript code to validate
126
+
127
+ Returns:
128
+ ValidationResult indicating if the script passes basic checks
129
+ """
130
+ errors: list[str] = []
131
+
132
+ if not script or not isinstance(script, str):
133
+ errors.append("Script must be a non-empty string")
134
+ return ValidationResult(False, errors)
135
+
136
+ if len(script) > 10000:
137
+ errors.append("Script is too long (max 10000 characters)")
138
+
139
+ # Check for obviously dangerous operations
140
+ dangerous_patterns = [
141
+ "document.cookie",
142
+ "localStorage",
143
+ "sessionStorage",
144
+ "window.location",
145
+ "eval(",
146
+ "Function(",
147
+ "document.write",
148
+ "XMLHttpRequest",
149
+ "fetch(",
150
+ ]
151
+
152
+ script_lower = script.lower()
153
+ matches = [pattern for pattern in dangerous_patterns if pattern.lower() in script_lower]
154
+
155
+ if matches:
156
+ errors.append(f"Script contains potentially dangerous operations: {', '.join(matches)}")
157
+
158
+ return ValidationResult(len(errors) == 0, errors)
159
+
160
+
161
+ def validate_action_type(action_type: str) -> ValidationResult:
162
+ """
163
+ Validate that an action type is supported.
164
+
165
+ Args:
166
+ action_type: The action type to validate
167
+
168
+ Returns:
169
+ ValidationResult indicating if the action type is valid
170
+ """
171
+ errors: list[str] = []
172
+
173
+ valid_types = {
174
+ "open_url", "click", "type", "scroll", "hover", "wait",
175
+ "screenshot", "inspect", "read_text", "execute",
176
+ "browser_close", "browser_refresh", "browser_back", "browser_forward",
177
+ }
178
+
179
+ if not action_type or not isinstance(action_type, str):
180
+ errors.append("Action type must be a non-empty string")
181
+ return ValidationResult(False, errors)
182
+
183
+ if action_type not in valid_types:
184
+ errors.append(f"Unknown action type: {action_type}. Valid types: {', '.join(sorted(valid_types))}")
185
+
186
+ return ValidationResult(len(errors) == 0, errors)
187
+
188
+
189
+ def validate_risk_level(risk_level: str) -> ValidationResult:
190
+ """
191
+ Validate that a risk level is within the allowed range.
192
+
193
+ Args:
194
+ risk_level: The risk level to validate
195
+
196
+ Returns:
197
+ ValidationResult indicating if the risk level is valid
198
+ """
199
+ errors: list[str] = []
200
+
201
+ valid_levels = {"safe", "low", "medium", "high", "blocked"}
202
+
203
+ if not risk_level or not isinstance(risk_level, str):
204
+ errors.append("Risk level must be a non-empty string")
205
+ return ValidationResult(False, errors)
206
+
207
+ if risk_level not in valid_levels:
208
+ errors.append(f"Invalid risk level: {risk_level}. Valid levels: {', '.join(sorted(valid_levels))}")
209
+
210
+ return ValidationResult(len(errors) == 0, errors)
211
+
212
+
213
+ def validate_text_input(value: str, max_length: int = 500, field_name: str = "value") -> ValidationResult:
214
+ """
215
+ Validate a general text input field.
216
+
217
+ Args:
218
+ value: The text value to validate
219
+ max_length: Maximum allowed length
220
+ field_name: Name of the field for error messages
221
+
222
+ Returns:
223
+ ValidationResult indicating if the input is valid
224
+ """
225
+ errors: list[str] = []
226
+
227
+ if not isinstance(value, str):
228
+ errors.append(f"{field_name} must be a string")
229
+ return ValidationResult(False, errors)
230
+
231
+ if len(value) > max_length:
232
+ errors.append(f"{field_name} is too long (max {max_length} characters)")
233
+
234
+ return ValidationResult(len(errors) == 0, errors)
235
+
236
+
237
+ def sanitize_text(text: str) -> str:
238
+ """
239
+ Sanitize text by removing or escaping potentially harmful characters.
240
+
241
+ Args:
242
+ text: The text to sanitize
243
+
244
+ Returns:
245
+ Sanitized text
246
+ """
247
+ if not text or not isinstance(text, str):
248
+ return ""
249
+
250
+ # Remove null bytes and other control characters except newlines and tabs
251
+ sanitized = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
252
+
253
+ # Trim whitespace
254
+ sanitized = sanitized.strip()
255
+
256
+ return sanitized
257
+
258
+
259
+ def validate_config(config: dict[str, Any]) -> ValidationResult:
260
+ """
261
+ Validate runtime configuration.
262
+
263
+ Args:
264
+ config: Configuration dictionary to validate
265
+
266
+ Returns:
267
+ ValidationResult indicating if the configuration is valid
268
+ """
269
+ errors: list[str] = []
270
+
271
+ # Validate host
272
+ if "runtime_host" in config:
273
+ host = config["runtime_host"]
274
+ if not isinstance(host, str):
275
+ errors.append("runtime_host must be a string")
276
+ elif host == "0.0.0.0":
277
+ errors.append("runtime_host cannot be 0.0.0.0 for security reasons")
278
+
279
+ # Validate port
280
+ if "runtime_port" in config:
281
+ port = config["runtime_port"]
282
+ if not isinstance(port, int) or not (1024 <= port <= 65535):
283
+ errors.append("runtime_port must be an integer between 1024 and 65535")
284
+
285
+ # Validate action_mode
286
+ if "action_mode" in config:
287
+ mode = config["action_mode"]
288
+ valid_modes = {"read-only", "assisted", "autonomous", "manual-override"}
289
+ if mode not in valid_modes:
290
+ errors.append(f"action_mode must be one of: {', '.join(sorted(valid_modes))}")
291
+
292
+ # Validate approval_policy
293
+ if "approval_policy" in config:
294
+ policy = config["approval_policy"]
295
+ valid_policies = {"safe", "low", "medium", "high", "blocked"}
296
+ if policy not in valid_policies:
297
+ errors.append(f"approval_policy must be one of: {', '.join(sorted(valid_policies))}")
298
+
299
+ return ValidationResult(len(errors) == 0, errors)
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "agent-portal"
3
+ version = "0.0.3"
4
+ description = "Python runtime package for Agent Portal."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "Magnexis" }
10
+ ]
11
+ dependencies = [
12
+ "playwright>=1.54.0"
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/magnexis/agent-portal"
17
+ Repository = "https://github.com/magnexis/agent-portal.git"
18
+ Issues = "https://github.com/magnexis/agent-portal/issues"
19
+
20
+ [project.scripts]
21
+ agent-portal-runtime = "agent_portal.server:main"
22
+ agent-portal = "agent_portal.cli:main"
23
+
24
+ [tool.flit.module]
25
+ name = "agent_portal"
26
+
27
+ [build-system]
28
+ requires = ["flit_core>=3.12.0,<4"]
29
+ build-backend = "flit_core.buildapi"