agent-loop-guard-runtime 0.6.0a2__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 (76) hide show
  1. agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
  2. agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
  3. agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
  4. agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
  5. agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
  6. agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
  7. app/__init__.py +6 -0
  8. app/api/__init__.py +1 -0
  9. app/api/admin_routes.py +179 -0
  10. app/api/anthropic_routes.py +21 -0
  11. app/api/common.py +457 -0
  12. app/api/mcp_routes.py +218 -0
  13. app/api/openai_routes.py +26 -0
  14. app/api/replay_routes.py +202 -0
  15. app/api/ui_routes.py +295 -0
  16. app/benchmark/__init__.py +2 -0
  17. app/benchmark/adapters.py +97 -0
  18. app/benchmark/data/starter-v1.json +38 -0
  19. app/benchmark/dataset.py +59 -0
  20. app/benchmark/models.py +46 -0
  21. app/benchmark/runner.py +63 -0
  22. app/benchmark/scorers.py +34 -0
  23. app/benchmark/statistics.py +48 -0
  24. app/benchmark/storage.py +78 -0
  25. app/cli.py +624 -0
  26. app/core/config.py +196 -0
  27. app/core/demo.py +109 -0
  28. app/core/loop_detector.py +120 -0
  29. app/core/policy_engine.py +167 -0
  30. app/core/redaction.py +84 -0
  31. app/core/security.py +54 -0
  32. app/core/token_meter.py +67 -0
  33. app/db/models.py +296 -0
  34. app/db/repository.py +1488 -0
  35. app/db/session.py +57 -0
  36. app/main.py +59 -0
  37. app/mcp/__init__.py +1 -0
  38. app/mcp/gateway.py +230 -0
  39. app/mcp/policy.py +281 -0
  40. app/mcp/presets/development.yml +25 -0
  41. app/mcp/presets/filesystem.yml +18 -0
  42. app/mcp/stdio.py +142 -0
  43. app/platform/__init__.py +1 -0
  44. app/platform/alembic/__init__.py +2 -0
  45. app/platform/alembic/env.py +38 -0
  46. app/platform/alembic/script.py.mako +24 -0
  47. app/platform/alembic/versions/0001_initial_schema.py +18 -0
  48. app/platform/alembic/versions/__init__.py +2 -0
  49. app/platform/events.py +44 -0
  50. app/platform/maintenance.py +138 -0
  51. app/platform/migrations.py +24 -0
  52. app/platform/setup.py +92 -0
  53. app/providers/__init__.py +5 -0
  54. app/providers/base.py +23 -0
  55. app/providers/mock.py +169 -0
  56. app/providers/upstream.py +101 -0
  57. app/replay/__init__.py +1 -0
  58. app/replay/costs.py +37 -0
  59. app/replay/formats.py +87 -0
  60. app/replay/sdk.py +102 -0
  61. app/sandbox/__init__.py +2 -0
  62. app/sandbox/policy.py +22 -0
  63. app/sandbox/workspace.py +281 -0
  64. app/static/styles.css +327 -0
  65. app/templates/agents.html +48 -0
  66. app/templates/base.html +27 -0
  67. app/templates/dashboard.html +55 -0
  68. app/templates/demo.html +36 -0
  69. app/templates/mcp.html +69 -0
  70. app/templates/policies.html +30 -0
  71. app/templates/replay.html +63 -0
  72. app/templates/replay_compare.html +74 -0
  73. app/templates/replay_detail.html +130 -0
  74. app/templates/session_detail.html +71 -0
  75. app/templates/sessions.html +24 -0
  76. app/templates/settings.html +20 -0
app/providers/mock.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import uuid
6
+ from collections.abc import AsyncIterator
7
+ from typing import Any
8
+
9
+ from app.core.token_meter import estimate_tokens
10
+ from app.providers.base import ProviderResult, ProviderStream
11
+
12
+
13
+ def _json_bytes(value: dict[str, Any]) -> bytes:
14
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
15
+
16
+
17
+ def _text_from_body(body: dict[str, Any]) -> str:
18
+ for key in ("input", "prompt"):
19
+ value = body.get(key)
20
+ if isinstance(value, str) and value.strip():
21
+ return value.strip()
22
+ messages = body.get("messages")
23
+ if isinstance(messages, list):
24
+ for item in reversed(messages):
25
+ if isinstance(item, dict):
26
+ content = item.get("content")
27
+ if isinstance(content, str) and content.strip():
28
+ return content.strip()
29
+ if isinstance(content, list):
30
+ parts = [
31
+ str(part.get("text"))
32
+ for part in content
33
+ if isinstance(part, dict) and part.get("text")
34
+ ]
35
+ if parts:
36
+ return " ".join(parts)
37
+ return "mock request"
38
+
39
+
40
+ def _usage(protocol: str, body: dict[str, Any], output: str) -> dict[str, int]:
41
+ input_tokens = estimate_tokens(body)
42
+ output_tokens = estimate_tokens(output)
43
+ if protocol == "anthropic":
44
+ return {"input_tokens": input_tokens, "output_tokens": output_tokens}
45
+ return {
46
+ "prompt_tokens": input_tokens,
47
+ "completion_tokens": output_tokens,
48
+ "total_tokens": input_tokens + output_tokens,
49
+ }
50
+
51
+
52
+ class MockProvider:
53
+ """Deterministic local provider used for demos and tests."""
54
+
55
+ async def models(self) -> ProviderResult:
56
+ payload = {
57
+ "object": "list",
58
+ "data": [
59
+ {"id": "demo-model", "object": "model", "created": 0, "owned_by": "agent-loop-guard"},
60
+ {"id": "mock-loop-model", "object": "model", "created": 0, "owned_by": "agent-loop-guard"},
61
+ ],
62
+ }
63
+ return ProviderResult(200, {"content-type": "application/json"}, _json_bytes(payload), payload)
64
+
65
+ async def count_tokens(self, body: dict[str, Any]) -> ProviderResult:
66
+ payload = {"input_tokens": estimate_tokens(body)}
67
+ return ProviderResult(200, {"content-type": "application/json"}, _json_bytes(payload), payload)
68
+
69
+ async def request(self, protocol: str, endpoint: str, body: dict[str, Any]) -> ProviderResult:
70
+ if endpoint.endswith("/models"):
71
+ return await self.models()
72
+ if endpoint.endswith("/messages/count_tokens"):
73
+ return await self.count_tokens(body)
74
+
75
+ status = int(body.get("mock_status") or 200)
76
+ if body.get("mock_error") or status >= 400:
77
+ payload = {
78
+ "error": {
79
+ "type": "mock_error",
80
+ "code": "mock_error",
81
+ "message": str(body.get("mock_error") or "mock upstream error"),
82
+ }
83
+ }
84
+ return ProviderResult(status, {"content-type": "application/json"}, _json_bytes(payload), payload)
85
+
86
+ prompt = _text_from_body(body)
87
+ output = f"Mock response for: {prompt[:120]}"
88
+ model = str(body.get("model") or "demo-model")
89
+ if protocol == "anthropic":
90
+ payload = {
91
+ "id": f"msg_{uuid.uuid4().hex[:16]}",
92
+ "type": "message",
93
+ "role": "assistant",
94
+ "model": model,
95
+ "content": [{"type": "text", "text": output}],
96
+ "stop_reason": "end_turn",
97
+ "usage": _usage(protocol, body, output),
98
+ }
99
+ elif endpoint.endswith("/responses"):
100
+ payload = {
101
+ "id": f"resp_{uuid.uuid4().hex[:16]}",
102
+ "object": "response",
103
+ "model": model,
104
+ "output": [
105
+ {
106
+ "type": "message",
107
+ "role": "assistant",
108
+ "content": [{"type": "output_text", "text": output}],
109
+ }
110
+ ],
111
+ "usage": _usage(protocol, body, output),
112
+ }
113
+ else:
114
+ payload = {
115
+ "id": f"chatcmpl_{uuid.uuid4().hex[:16]}",
116
+ "object": "chat.completion",
117
+ "model": model,
118
+ "choices": [
119
+ {
120
+ "index": 0,
121
+ "finish_reason": "stop",
122
+ "message": {"role": "assistant", "content": output},
123
+ }
124
+ ],
125
+ "usage": _usage(protocol, body, output),
126
+ }
127
+ return ProviderResult(200, {"content-type": "application/json"}, _json_bytes(payload), payload)
128
+
129
+ async def stream(self, protocol: str, endpoint: str, body: dict[str, Any]) -> ProviderStream:
130
+ prompt = _text_from_body(body)
131
+ model = str(body.get("model") or "demo-model")
132
+ chunks = [
133
+ f"Mock streaming response for: {prompt[:80]}",
134
+ " -- guarded by Agent Loop Guard.",
135
+ ]
136
+
137
+ async def iterator() -> AsyncIterator[bytes]:
138
+ if protocol == "anthropic":
139
+ yield b"event: message_start\n"
140
+ start = {"type": "message_start", "message": {"id": "msg_mock", "model": model}}
141
+ yield b"data: " + _json_bytes(start) + b"\n\n"
142
+ for chunk in chunks:
143
+ await asyncio.sleep(0)
144
+ payload = {"type": "content_block_delta", "delta": {"type": "text_delta", "text": chunk}}
145
+ yield b"event: content_block_delta\n"
146
+ yield b"data: " + _json_bytes(payload) + b"\n\n"
147
+ usage = _usage(protocol, body, "".join(chunks))
148
+ yield b"event: message_delta\n"
149
+ yield b"data: " + _json_bytes({"type": "message_delta", "usage": usage}) + b"\n\n"
150
+ yield b"event: message_stop\n"
151
+ yield b"data: {\"type\":\"message_stop\"}\n\n"
152
+ return
153
+
154
+ for chunk in chunks:
155
+ await asyncio.sleep(0)
156
+ if endpoint.endswith("/responses"):
157
+ payload = {"type": "response.output_text.delta", "delta": chunk}
158
+ else:
159
+ payload = {
160
+ "id": "chatcmpl_mock",
161
+ "object": "chat.completion.chunk",
162
+ "model": model,
163
+ "choices": [{"index": 0, "delta": {"content": chunk}, "finish_reason": None}],
164
+ }
165
+ yield b"data: " + _json_bytes(payload) + b"\n\n"
166
+ yield b"data: [DONE]\n\n"
167
+
168
+ return ProviderStream(200, {"content-type": "text/event-stream"}, iterator())
169
+
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator
4
+ from urllib.parse import urljoin, urlparse
5
+
6
+ import httpx
7
+
8
+ from app.core.security import filtered_upstream_headers
9
+ from app.providers.base import ProviderResult, ProviderStream
10
+
11
+
12
+ def validate_base_url(base_url: str) -> str:
13
+ parsed = urlparse(base_url)
14
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
15
+ raise ValueError("Upstream base URL must be an absolute http(s) URL.")
16
+ return base_url.rstrip("/") + "/"
17
+
18
+
19
+ def _target(base_url: str, endpoint: str) -> str:
20
+ return urljoin(validate_base_url(base_url), endpoint.lstrip("/"))
21
+
22
+
23
+ def _response_headers(headers: httpx.Headers) -> dict[str, str]:
24
+ hop_by_hop = {
25
+ "connection",
26
+ "content-length",
27
+ "keep-alive",
28
+ "proxy-authenticate",
29
+ "proxy-authorization",
30
+ "te",
31
+ "trailer",
32
+ "transfer-encoding",
33
+ "upgrade",
34
+ }
35
+ return {key: value for key, value in headers.items() if key.lower() not in hop_by_hop}
36
+
37
+
38
+ class UpstreamProvider:
39
+ def __init__(self, *, base_url: str, api_key: str | None, protocol: str):
40
+ self.base_url = validate_base_url(base_url)
41
+ self.api_key = api_key
42
+ self.protocol = protocol
43
+
44
+ def _headers(self, incoming: dict[str, str]) -> dict[str, str]:
45
+ headers = filtered_upstream_headers(incoming)
46
+ if self.api_key:
47
+ if self.protocol == "anthropic":
48
+ headers["x-api-key"] = self.api_key
49
+ headers.setdefault("anthropic-version", "2023-06-01")
50
+ else:
51
+ headers["authorization"] = f"Bearer {self.api_key}"
52
+ return headers
53
+
54
+ async def request(self, endpoint: str, raw_body: bytes, incoming_headers: dict[str, str]) -> ProviderResult:
55
+ async with httpx.AsyncClient(timeout=None) as client:
56
+ response = await client.request(
57
+ "POST" if raw_body else "GET",
58
+ _target(self.base_url, endpoint),
59
+ content=raw_body if raw_body else None,
60
+ headers=self._headers(incoming_headers),
61
+ )
62
+ json_body = None
63
+ try:
64
+ json_body = response.json()
65
+ except ValueError:
66
+ pass
67
+ return ProviderResult(
68
+ response.status_code,
69
+ _response_headers(response.headers),
70
+ response.content,
71
+ json_body,
72
+ response.headers.get("content-type"),
73
+ )
74
+
75
+ async def stream(
76
+ self, endpoint: str, raw_body: bytes, incoming_headers: dict[str, str]
77
+ ) -> ProviderStream:
78
+ client = httpx.AsyncClient(timeout=None)
79
+ request = client.build_request(
80
+ "POST",
81
+ _target(self.base_url, endpoint),
82
+ content=raw_body,
83
+ headers=self._headers(incoming_headers),
84
+ )
85
+ response = await client.send(request, stream=True)
86
+
87
+ async def iterator() -> AsyncIterator[bytes]:
88
+ try:
89
+ async for chunk in response.aiter_bytes():
90
+ yield chunk
91
+ finally:
92
+ await response.aclose()
93
+ await client.aclose()
94
+
95
+ return ProviderStream(
96
+ response.status_code,
97
+ _response_headers(response.headers),
98
+ iterator(),
99
+ response.headers.get("content-type") or "text/event-stream",
100
+ )
101
+
app/replay/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Session Replay SDK and export helpers."""
app/replay/costs.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ def load_pricing(path: str | Path | None = None) -> dict[str, dict[str, int]]:
11
+ selected = Path(path or os.getenv("ALG_MODEL_PRICING", "model-pricing.yml"))
12
+ if not selected.exists():
13
+ return {"demo-model": {"input_micros_per_million": 0, "output_micros_per_million": 0}}
14
+ with selected.open(encoding="utf-8") as handle:
15
+ data: dict[str, Any] = yaml.safe_load(handle) or {}
16
+ return {
17
+ str(model): {
18
+ "input_micros_per_million": int(row.get("input_micros_per_million", 0)),
19
+ "output_micros_per_million": int(row.get("output_micros_per_million", 0)),
20
+ }
21
+ for model, row in (data.get("models") or {}).items()
22
+ if isinstance(row, dict)
23
+ }
24
+
25
+
26
+ def estimate_cost_micros(
27
+ model: str | None, input_tokens: int, output_tokens: int, pricing: dict | None = None
28
+ ) -> tuple[int, bool]:
29
+ catalog = pricing or load_pricing()
30
+ row = catalog.get(model or "")
31
+ if not row:
32
+ return 0, True
33
+ total = (
34
+ input_tokens * int(row.get("input_micros_per_million", 0))
35
+ + output_tokens * int(row.get("output_micros_per_million", 0))
36
+ ) // 1_000_000
37
+ return total, True
app/replay/formats.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ def trace_to_jsonl(bundle: dict[str, Any]) -> str:
9
+ rows = [{"record_type": "run", **bundle["run"]}]
10
+ rows.extend({"record_type": "span", **item} for item in bundle.get("spans", []))
11
+ rows.extend({"record_type": "event", **item} for item in bundle.get("events", []))
12
+ rows.extend({"record_type": "artifact", **item} for item in bundle.get("artifacts", []))
13
+ return "".join(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n" for row in rows)
14
+
15
+
16
+ def trace_to_otel(bundle: dict[str, Any]) -> dict[str, Any]:
17
+ run = bundle["run"]
18
+ trace_id = hashlib.sha256(str(run["id"]).encode("utf-8")).hexdigest()[:32]
19
+
20
+ def otel_span_id(value: str) -> str:
21
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
22
+
23
+ spans = []
24
+ events_by_span: dict[str, list[dict[str, Any]]] = {}
25
+ for event in bundle.get("events", []):
26
+ if event.get("span_id"):
27
+ events_by_span.setdefault(event["span_id"], []).append(event)
28
+ for span in bundle.get("spans", []):
29
+ spans.append(
30
+ {
31
+ "traceId": trace_id,
32
+ "spanId": otel_span_id(span["id"]),
33
+ "parentSpanId": (
34
+ otel_span_id(span["parent_span_id"])
35
+ if span.get("parent_span_id")
36
+ else ""
37
+ ),
38
+ "name": span["name"],
39
+ "startTimeUnixNano": str(span["start_ns"]),
40
+ "endTimeUnixNano": str(span.get("end_ns") or span["start_ns"]),
41
+ "status": {"code": "STATUS_CODE_ERROR" if span["status"] in {"error", "blocked"} else "STATUS_CODE_OK"},
42
+ "attributes": [
43
+ {"key": str(key), "value": {"stringValue": str(value)}}
44
+ for key, value in span.get("attributes", {}).items()
45
+ ],
46
+ "events": [
47
+ {
48
+ "timeUnixNano": str(event["timestamp_ns"]),
49
+ "name": event["name"],
50
+ "attributes": [
51
+ {"key": str(key), "value": {"stringValue": str(value)}}
52
+ for key, value in event.get("attributes", {}).items()
53
+ ],
54
+ }
55
+ for event in events_by_span.get(span["id"], [])
56
+ ],
57
+ }
58
+ )
59
+ return {
60
+ "resourceSpans": [
61
+ {
62
+ "resource": {
63
+ "attributes": [
64
+ {"key": "service.name", "value": {"stringValue": "agent-loop-guard"}},
65
+ {"key": "project.id", "value": {"stringValue": run["project_id"]}},
66
+ ]
67
+ },
68
+ "scopeSpans": [{"scope": {"name": "agent-loop-guard.replay"}, "spans": spans}],
69
+ }
70
+ ]
71
+ }
72
+
73
+
74
+ def jsonl_to_trace(text: str) -> dict[str, Any]:
75
+ bundle: dict[str, Any] = {"spans": [], "events": [], "artifacts": []}
76
+ for line in text.splitlines():
77
+ if not line.strip():
78
+ continue
79
+ row = json.loads(line)
80
+ record_type = row.pop("record_type", None)
81
+ if record_type == "run":
82
+ bundle["run"] = row
83
+ elif record_type in {"span", "event", "artifact"}:
84
+ bundle[f"{record_type}s"].append(row)
85
+ if "run" not in bundle:
86
+ raise ValueError("JSONL bundle does not contain a run record.")
87
+ return bundle
app/replay/sdk.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from contextlib import AbstractContextManager
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from app.core.redaction import redact_value
12
+
13
+
14
+ class ReplayClient:
15
+ def __init__(self, base_url: str = "http://127.0.0.1:8787", timeout: float = 10):
16
+ self.base_url = base_url.rstrip("/")
17
+ self.timeout = timeout
18
+
19
+ def trace(self, **attributes: Any) -> TraceContext:
20
+ return TraceContext(self, attributes)
21
+
22
+ def ingest(self, payload: dict[str, Any]) -> dict[str, Any]:
23
+ response = httpx.post(
24
+ f"{self.base_url}/api/v1/traces", json=redact_value(payload), timeout=self.timeout
25
+ )
26
+ response.raise_for_status()
27
+ return response.json()
28
+
29
+
30
+ @dataclass
31
+ class SpanContext(AbstractContextManager["SpanContext"]):
32
+ trace: TraceContext
33
+ name: str
34
+ attributes: dict[str, Any] = field(default_factory=dict)
35
+ parent_span_id: str | None = None
36
+ span_id: str = field(default_factory=lambda: f"spn_{uuid.uuid4().hex[:24]}")
37
+ start_ns: int = field(default_factory=time.time_ns)
38
+ end_ns: int | None = None
39
+ status: str = "ok"
40
+ events: list[dict[str, Any]] = field(default_factory=list)
41
+
42
+ def event(self, name: str, severity: str = "info", **attributes: Any) -> None:
43
+ self.events.append(
44
+ {
45
+ "name": name,
46
+ "severity": severity,
47
+ "timestamp_ns": time.time_ns(),
48
+ "attributes": attributes,
49
+ }
50
+ )
51
+
52
+ def __exit__(self, exc_type, exc, traceback) -> bool:
53
+ self.end_ns = time.time_ns()
54
+ if exc is not None:
55
+ self.status = "error"
56
+ self.event("exception", "error", type=type(exc).__name__, message=str(exc))
57
+ self.trace.spans.append(
58
+ {
59
+ "span_id": self.span_id,
60
+ "parent_span_id": self.parent_span_id,
61
+ "name": self.name,
62
+ "status": self.status,
63
+ "start_ns": self.start_ns,
64
+ "end_ns": self.end_ns,
65
+ "attributes": self.attributes,
66
+ "events": self.events,
67
+ }
68
+ )
69
+ return False
70
+
71
+
72
+ @dataclass
73
+ class TraceContext(AbstractContextManager["TraceContext"]):
74
+ client: ReplayClient
75
+ attributes: dict[str, Any] = field(default_factory=dict)
76
+ trace_id: str = field(default_factory=lambda: f"trc_{uuid.uuid4().hex[:24]}")
77
+ start_ns: int = field(default_factory=time.time_ns)
78
+ spans: list[dict[str, Any]] = field(default_factory=list)
79
+ result: dict[str, Any] | None = None
80
+
81
+ def span(
82
+ self,
83
+ name: str,
84
+ *,
85
+ parent_span_id: str | None = None,
86
+ **attributes: Any,
87
+ ) -> SpanContext:
88
+ return SpanContext(self, name, attributes, parent_span_id)
89
+
90
+ def __exit__(self, exc_type, exc, traceback) -> bool:
91
+ status = "error" if exc is not None else "ok"
92
+ self.result = self.client.ingest(
93
+ {
94
+ "trace_id": self.trace_id,
95
+ "status": status,
96
+ "start_ns": self.start_ns,
97
+ "end_ns": time.time_ns(),
98
+ "attributes": self.attributes,
99
+ "spans": self.spans,
100
+ }
101
+ )
102
+ return False
@@ -0,0 +1,2 @@
1
+ """Docker-backed, Linux-first sandbox technical preview."""
2
+
app/sandbox/policy.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ DENIED_COMMAND_PATTERNS = (
6
+ r"\b(?:docker|podman)\b",
7
+ r"\bmount\b",
8
+ r"\b(?:shutdown|reboot|poweroff)\b",
9
+ r"\bnsenter\b",
10
+ r"/proc/(?:1|sysrq-trigger)",
11
+ r":\s*\(\s*\)\s*\{.*:\s*\|\s*:",
12
+ )
13
+
14
+
15
+ def validate_command(command: list[str]) -> None:
16
+ if not command:
17
+ raise ValueError("A command is required after --")
18
+ rendered = " ".join(command)
19
+ for pattern in DENIED_COMMAND_PATTERNS:
20
+ if re.search(pattern, rendered, re.IGNORECASE):
21
+ raise ValueError(f"Command denied by sandbox policy: {pattern}")
22
+