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.
- agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
- app/__init__.py +6 -0
- app/api/__init__.py +1 -0
- app/api/admin_routes.py +179 -0
- app/api/anthropic_routes.py +21 -0
- app/api/common.py +457 -0
- app/api/mcp_routes.py +218 -0
- app/api/openai_routes.py +26 -0
- app/api/replay_routes.py +202 -0
- app/api/ui_routes.py +295 -0
- app/benchmark/__init__.py +2 -0
- app/benchmark/adapters.py +97 -0
- app/benchmark/data/starter-v1.json +38 -0
- app/benchmark/dataset.py +59 -0
- app/benchmark/models.py +46 -0
- app/benchmark/runner.py +63 -0
- app/benchmark/scorers.py +34 -0
- app/benchmark/statistics.py +48 -0
- app/benchmark/storage.py +78 -0
- app/cli.py +624 -0
- app/core/config.py +196 -0
- app/core/demo.py +109 -0
- app/core/loop_detector.py +120 -0
- app/core/policy_engine.py +167 -0
- app/core/redaction.py +84 -0
- app/core/security.py +54 -0
- app/core/token_meter.py +67 -0
- app/db/models.py +296 -0
- app/db/repository.py +1488 -0
- app/db/session.py +57 -0
- app/main.py +59 -0
- app/mcp/__init__.py +1 -0
- app/mcp/gateway.py +230 -0
- app/mcp/policy.py +281 -0
- app/mcp/presets/development.yml +25 -0
- app/mcp/presets/filesystem.yml +18 -0
- app/mcp/stdio.py +142 -0
- app/platform/__init__.py +1 -0
- app/platform/alembic/__init__.py +2 -0
- app/platform/alembic/env.py +38 -0
- app/platform/alembic/script.py.mako +24 -0
- app/platform/alembic/versions/0001_initial_schema.py +18 -0
- app/platform/alembic/versions/__init__.py +2 -0
- app/platform/events.py +44 -0
- app/platform/maintenance.py +138 -0
- app/platform/migrations.py +24 -0
- app/platform/setup.py +92 -0
- app/providers/__init__.py +5 -0
- app/providers/base.py +23 -0
- app/providers/mock.py +169 -0
- app/providers/upstream.py +101 -0
- app/replay/__init__.py +1 -0
- app/replay/costs.py +37 -0
- app/replay/formats.py +87 -0
- app/replay/sdk.py +102 -0
- app/sandbox/__init__.py +2 -0
- app/sandbox/policy.py +22 -0
- app/sandbox/workspace.py +281 -0
- app/static/styles.css +327 -0
- app/templates/agents.html +48 -0
- app/templates/base.html +27 -0
- app/templates/dashboard.html +55 -0
- app/templates/demo.html +36 -0
- app/templates/mcp.html +69 -0
- app/templates/policies.html +30 -0
- app/templates/replay.html +63 -0
- app/templates/replay_compare.html +74 -0
- app/templates/replay_detail.html +130 -0
- app/templates/session_detail.html +71 -0
- app/templates/sessions.html +24 -0
- app/templates/settings.html +20 -0
app/sandbox/workspace.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import uuid
|
|
11
|
+
import zipfile
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from app.sandbox.policy import validate_command
|
|
17
|
+
|
|
18
|
+
IGNORED_NAMES = {".git", ".agent-loop-guard", ".venv", "node_modules", "__pycache__"}
|
|
19
|
+
SESSION_PATTERN = re.compile(r"^sbx_[a-f0-9]{24}$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def docker_available() -> tuple[bool, str]:
|
|
23
|
+
executable = shutil.which("docker")
|
|
24
|
+
if not executable:
|
|
25
|
+
return False, "Docker CLI is not installed. Install Docker in WSL2 or Docker Desktop first."
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
[executable, "version", "--format", "{{.Server.Version}}"],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
timeout=5,
|
|
32
|
+
check=False,
|
|
33
|
+
)
|
|
34
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
35
|
+
return False, str(exc)
|
|
36
|
+
if result.returncode:
|
|
37
|
+
return False, result.stderr.strip() or "Docker daemon is not available."
|
|
38
|
+
return True, result.stdout.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_docker_command(
|
|
42
|
+
workspace: Path,
|
|
43
|
+
image: str,
|
|
44
|
+
command: list[str],
|
|
45
|
+
*,
|
|
46
|
+
network: str = "none",
|
|
47
|
+
cpus: float = 1.0,
|
|
48
|
+
memory: str = "1g",
|
|
49
|
+
pids: int = 128,
|
|
50
|
+
) -> list[str]:
|
|
51
|
+
validate_command(command)
|
|
52
|
+
user = f"{os.getuid()}:{os.getgid()}" if hasattr(os, "getuid") else "1000:1000"
|
|
53
|
+
return [
|
|
54
|
+
"docker",
|
|
55
|
+
"run",
|
|
56
|
+
"--rm",
|
|
57
|
+
"--init",
|
|
58
|
+
"--user",
|
|
59
|
+
user,
|
|
60
|
+
"--network",
|
|
61
|
+
network,
|
|
62
|
+
"--cpus",
|
|
63
|
+
str(cpus),
|
|
64
|
+
"--memory",
|
|
65
|
+
memory,
|
|
66
|
+
"--pids-limit",
|
|
67
|
+
str(pids),
|
|
68
|
+
"--cap-drop",
|
|
69
|
+
"ALL",
|
|
70
|
+
"--security-opt",
|
|
71
|
+
"no-new-privileges",
|
|
72
|
+
"--read-only",
|
|
73
|
+
"--tmpfs",
|
|
74
|
+
"/tmp:rw,noexec,nosuid,size=128m",
|
|
75
|
+
"--mount",
|
|
76
|
+
f"type=bind,source={workspace.resolve()},target=/workspace",
|
|
77
|
+
"--workdir",
|
|
78
|
+
"/workspace",
|
|
79
|
+
image,
|
|
80
|
+
*command,
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SandboxWorkspace:
|
|
85
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
86
|
+
self.root = (root or Path(".agent-loop-guard/sandboxes")).resolve()
|
|
87
|
+
|
|
88
|
+
def create(self, source: Path, image: str = "python:3.12-slim") -> dict[str, Any]:
|
|
89
|
+
available, detail = docker_available()
|
|
90
|
+
if not available:
|
|
91
|
+
raise RuntimeError(detail)
|
|
92
|
+
source = source.resolve()
|
|
93
|
+
_ensure_safe_source(source)
|
|
94
|
+
session_id = f"sbx_{uuid.uuid4().hex[:24]}"
|
|
95
|
+
session_root = self.root / session_id
|
|
96
|
+
snapshot = session_root / "snapshot"
|
|
97
|
+
workspace = session_root / "workspace"
|
|
98
|
+
session_root.mkdir(parents=True)
|
|
99
|
+
_copy_tree(source, snapshot)
|
|
100
|
+
_copy_tree(source, workspace)
|
|
101
|
+
manifest = {
|
|
102
|
+
"schema_version": "sandbox.v1",
|
|
103
|
+
"id": session_id,
|
|
104
|
+
"source": str(source),
|
|
105
|
+
"image": image,
|
|
106
|
+
"docker_version": detail,
|
|
107
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
108
|
+
"original_hashes": _file_hashes(snapshot),
|
|
109
|
+
}
|
|
110
|
+
_write_manifest(session_root, manifest)
|
|
111
|
+
return manifest
|
|
112
|
+
|
|
113
|
+
def execute(
|
|
114
|
+
self,
|
|
115
|
+
session_id: str,
|
|
116
|
+
command: list[str],
|
|
117
|
+
*,
|
|
118
|
+
timeout: float = 300,
|
|
119
|
+
network: str = "none",
|
|
120
|
+
cpus: float = 1.0,
|
|
121
|
+
memory: str = "1g",
|
|
122
|
+
pids: int = 128,
|
|
123
|
+
) -> subprocess.CompletedProcess[str]:
|
|
124
|
+
session_root, manifest = self._load(session_id)
|
|
125
|
+
docker_command = build_docker_command(
|
|
126
|
+
session_root / "workspace",
|
|
127
|
+
str(manifest["image"]),
|
|
128
|
+
command,
|
|
129
|
+
network=network,
|
|
130
|
+
cpus=cpus,
|
|
131
|
+
memory=memory,
|
|
132
|
+
pids=pids,
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
return subprocess.run(
|
|
136
|
+
docker_command,
|
|
137
|
+
text=True,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
timeout=timeout,
|
|
140
|
+
check=False,
|
|
141
|
+
)
|
|
142
|
+
except subprocess.TimeoutExpired as exc:
|
|
143
|
+
raise RuntimeError(f"Sandbox command timed out after {timeout:g}s") from exc
|
|
144
|
+
|
|
145
|
+
def diff(self, session_id: str) -> list[dict[str, Any]]:
|
|
146
|
+
session_root, _ = self._load(session_id)
|
|
147
|
+
snapshot = session_root / "snapshot"
|
|
148
|
+
workspace = session_root / "workspace"
|
|
149
|
+
before = _file_hashes(snapshot)
|
|
150
|
+
after = _file_hashes(workspace)
|
|
151
|
+
rows = []
|
|
152
|
+
for path in sorted(set(before) | set(after)):
|
|
153
|
+
status = "added" if path not in before else "deleted" if path not in after else "modified"
|
|
154
|
+
if before.get(path) == after.get(path):
|
|
155
|
+
continue
|
|
156
|
+
rows.append(
|
|
157
|
+
{
|
|
158
|
+
"path": path,
|
|
159
|
+
"status": status,
|
|
160
|
+
"before_sha256": before.get(path),
|
|
161
|
+
"after_sha256": after.get(path),
|
|
162
|
+
"patch": _text_diff(snapshot / path, workspace / path, path),
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
return rows
|
|
166
|
+
|
|
167
|
+
def apply(self, session_id: str, paths: list[str] | None = None) -> list[str]:
|
|
168
|
+
session_root, manifest = self._load(session_id)
|
|
169
|
+
source = Path(str(manifest["source"])).resolve()
|
|
170
|
+
snapshot = session_root / "snapshot"
|
|
171
|
+
workspace = session_root / "workspace"
|
|
172
|
+
changes = {item["path"]: item for item in self.diff(session_id)}
|
|
173
|
+
selected = sorted(changes) if paths is None else paths
|
|
174
|
+
unknown = sorted(set(selected) - set(changes))
|
|
175
|
+
if unknown:
|
|
176
|
+
raise ValueError(f"Paths are not changed: {', '.join(unknown)}")
|
|
177
|
+
applied = []
|
|
178
|
+
for relative in selected:
|
|
179
|
+
source_path = _contained(source, relative)
|
|
180
|
+
snapshot_path = _contained(snapshot, relative)
|
|
181
|
+
workspace_path = _contained(workspace, relative)
|
|
182
|
+
expected = _hash_file(snapshot_path) if snapshot_path.is_file() else None
|
|
183
|
+
current = _hash_file(source_path) if source_path.is_file() else None
|
|
184
|
+
if current != expected:
|
|
185
|
+
raise RuntimeError(f"Source changed since sandbox creation: {relative}")
|
|
186
|
+
if workspace_path.is_symlink():
|
|
187
|
+
raise RuntimeError(f"Refusing to apply symlink: {relative}")
|
|
188
|
+
if changes[relative]["status"] == "deleted":
|
|
189
|
+
source_path.unlink(missing_ok=True)
|
|
190
|
+
else:
|
|
191
|
+
source_path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
shutil.copy2(workspace_path, source_path)
|
|
193
|
+
applied.append(relative)
|
|
194
|
+
return applied
|
|
195
|
+
|
|
196
|
+
def discard(self, session_id: str) -> None:
|
|
197
|
+
session_root, _ = self._load(session_id)
|
|
198
|
+
shutil.rmtree(session_root)
|
|
199
|
+
|
|
200
|
+
def export(self, session_id: str, destination: Path) -> Path:
|
|
201
|
+
session_root, _ = self._load(session_id)
|
|
202
|
+
destination = destination.resolve()
|
|
203
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
with zipfile.ZipFile(destination, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
205
|
+
archive.write(session_root / "manifest.json", "manifest.json")
|
|
206
|
+
archive.writestr("diff.json", json.dumps(self.diff(session_id), indent=2))
|
|
207
|
+
for path in (session_root / "workspace").rglob("*"):
|
|
208
|
+
if path.is_file() and not path.is_symlink():
|
|
209
|
+
archive.write(path, Path("workspace") / path.relative_to(session_root / "workspace"))
|
|
210
|
+
return destination
|
|
211
|
+
|
|
212
|
+
def _load(self, session_id: str) -> tuple[Path, dict[str, Any]]:
|
|
213
|
+
if not SESSION_PATTERN.fullmatch(session_id):
|
|
214
|
+
raise ValueError("Invalid sandbox id")
|
|
215
|
+
session_root = (self.root / session_id).resolve()
|
|
216
|
+
if session_root.parent != self.root or not session_root.is_dir():
|
|
217
|
+
raise FileNotFoundError(f"Sandbox not found: {session_id}")
|
|
218
|
+
manifest = json.loads((session_root / "manifest.json").read_text(encoding="utf-8"))
|
|
219
|
+
return session_root, manifest
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _ensure_safe_source(source: Path) -> None:
|
|
223
|
+
if not source.is_dir():
|
|
224
|
+
raise ValueError(f"Source directory does not exist: {source}")
|
|
225
|
+
for path in source.rglob("*"):
|
|
226
|
+
if any(part in IGNORED_NAMES for part in path.relative_to(source).parts):
|
|
227
|
+
continue
|
|
228
|
+
if path.is_symlink():
|
|
229
|
+
raise ValueError(f"Source contains a symlink; sandbox creation refused: {path}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _ignore(_directory: str, names: list[str]) -> set[str]:
|
|
233
|
+
return set(names) & IGNORED_NAMES
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _copy_tree(source: Path, destination: Path) -> None:
|
|
237
|
+
shutil.copytree(source, destination, ignore=_ignore)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _write_manifest(session_root: Path, manifest: dict[str, Any]) -> None:
|
|
241
|
+
(session_root / "manifest.json").write_text(
|
|
242
|
+
json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _file_hashes(root: Path) -> dict[str, str]:
|
|
247
|
+
return {
|
|
248
|
+
path.relative_to(root).as_posix(): _hash_file(path)
|
|
249
|
+
for path in root.rglob("*")
|
|
250
|
+
if path.is_file() and not path.is_symlink()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _hash_file(path: Path) -> str:
|
|
255
|
+
digest = hashlib.sha256()
|
|
256
|
+
with path.open("rb") as handle:
|
|
257
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
258
|
+
digest.update(chunk)
|
|
259
|
+
return digest.hexdigest()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _contained(root: Path, relative: str) -> Path:
|
|
263
|
+
if Path(relative).is_absolute() or ".." in Path(relative).parts:
|
|
264
|
+
raise ValueError(f"Unsafe relative path: {relative}")
|
|
265
|
+
candidate = (root / relative).resolve()
|
|
266
|
+
try:
|
|
267
|
+
candidate.relative_to(root.resolve())
|
|
268
|
+
except ValueError as exc:
|
|
269
|
+
raise ValueError(f"Path escapes workspace: {relative}") from exc
|
|
270
|
+
return candidate
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _text_diff(before: Path, after: Path, label: str) -> str | None:
|
|
274
|
+
try:
|
|
275
|
+
before_text = before.read_text(encoding="utf-8").splitlines(keepends=True) if before.exists() else []
|
|
276
|
+
after_text = after.read_text(encoding="utf-8").splitlines(keepends=True) if after.exists() else []
|
|
277
|
+
except (OSError, UnicodeDecodeError):
|
|
278
|
+
return None
|
|
279
|
+
return "".join(
|
|
280
|
+
difflib.unified_diff(before_text, after_text, fromfile=f"a/{label}", tofile=f"b/{label}")
|
|
281
|
+
)
|
app/static/styles.css
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
4
|
+
background: #f6f7f9;
|
|
5
|
+
color: #18202a;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
min-height: 100vh;
|
|
15
|
+
display: grid;
|
|
16
|
+
grid-template-columns: 240px 1fr;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
a {
|
|
20
|
+
color: #0b5cad;
|
|
21
|
+
text-decoration: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.sidebar {
|
|
25
|
+
background: #101820;
|
|
26
|
+
color: #f7f9fb;
|
|
27
|
+
padding: 24px 18px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.brand {
|
|
31
|
+
color: #fff;
|
|
32
|
+
display: block;
|
|
33
|
+
font-size: 19px;
|
|
34
|
+
font-weight: 700;
|
|
35
|
+
margin-bottom: 24px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
nav {
|
|
39
|
+
display: grid;
|
|
40
|
+
gap: 6px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
nav a {
|
|
44
|
+
color: #c9d3df;
|
|
45
|
+
padding: 9px 10px;
|
|
46
|
+
border-radius: 6px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
nav a:hover {
|
|
50
|
+
background: #1d2a36;
|
|
51
|
+
color: #fff;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.content {
|
|
55
|
+
padding: 28px 34px 48px;
|
|
56
|
+
min-width: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.page-head {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
justify-content: space-between;
|
|
63
|
+
gap: 16px;
|
|
64
|
+
margin-bottom: 18px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
h1, h2 {
|
|
68
|
+
letter-spacing: 0;
|
|
69
|
+
margin: 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
h1 {
|
|
73
|
+
font-size: 28px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
h2 {
|
|
77
|
+
font-size: 18px;
|
|
78
|
+
margin-bottom: 14px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.muted {
|
|
82
|
+
color: #667382;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.metric-grid {
|
|
86
|
+
display: grid;
|
|
87
|
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
88
|
+
gap: 12px;
|
|
89
|
+
margin-bottom: 18px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.metric, .panel, .notice {
|
|
93
|
+
background: #fff;
|
|
94
|
+
border: 1px solid #dde3ea;
|
|
95
|
+
border-radius: 8px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.metric {
|
|
99
|
+
padding: 14px 16px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.metric span {
|
|
103
|
+
color: #667382;
|
|
104
|
+
display: block;
|
|
105
|
+
font-size: 13px;
|
|
106
|
+
margin-bottom: 6px;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.metric strong {
|
|
110
|
+
font-size: 24px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.panel {
|
|
114
|
+
padding: 18px;
|
|
115
|
+
margin-bottom: 18px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.notice {
|
|
119
|
+
padding: 14px 16px;
|
|
120
|
+
margin-bottom: 18px;
|
|
121
|
+
background: #eef8f2;
|
|
122
|
+
border-color: #b9dec4;
|
|
123
|
+
display: flex;
|
|
124
|
+
gap: 16px;
|
|
125
|
+
align-items: center;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
table {
|
|
129
|
+
width: 100%;
|
|
130
|
+
border-collapse: collapse;
|
|
131
|
+
table-layout: fixed;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
th, td {
|
|
135
|
+
border-bottom: 1px solid #e7ebf0;
|
|
136
|
+
padding: 10px 8px;
|
|
137
|
+
text-align: left;
|
|
138
|
+
vertical-align: middle;
|
|
139
|
+
overflow-wrap: anywhere;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
th {
|
|
143
|
+
color: #596777;
|
|
144
|
+
font-size: 12px;
|
|
145
|
+
text-transform: uppercase;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
code {
|
|
149
|
+
background: #f1f3f6;
|
|
150
|
+
border: 1px solid #dfe5eb;
|
|
151
|
+
border-radius: 5px;
|
|
152
|
+
padding: 2px 5px;
|
|
153
|
+
font-size: 12px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
button, .button {
|
|
157
|
+
appearance: none;
|
|
158
|
+
border: 1px solid #0c5a9e;
|
|
159
|
+
background: #0c5a9e;
|
|
160
|
+
color: #fff;
|
|
161
|
+
border-radius: 6px;
|
|
162
|
+
padding: 8px 12px;
|
|
163
|
+
font: inherit;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
display: inline-flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
min-height: 36px;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
button.danger {
|
|
171
|
+
background: #a32929;
|
|
172
|
+
border-color: #a32929;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
button.secondary {
|
|
176
|
+
background: #fff;
|
|
177
|
+
color: #0c5a9e;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.timeline {
|
|
181
|
+
display: grid;
|
|
182
|
+
gap: 8px;
|
|
183
|
+
margin-bottom: 18px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.timeline-row {
|
|
187
|
+
display: grid;
|
|
188
|
+
grid-template-columns: minmax(150px, 220px) 1fr 80px;
|
|
189
|
+
gap: 10px;
|
|
190
|
+
align-items: center;
|
|
191
|
+
padding-left: calc(var(--depth) * 12px);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.timeline-track {
|
|
195
|
+
position: relative;
|
|
196
|
+
height: 12px;
|
|
197
|
+
border-radius: 4px;
|
|
198
|
+
background: #edf1f5;
|
|
199
|
+
overflow: hidden;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.timeline-bar {
|
|
203
|
+
position: absolute;
|
|
204
|
+
top: 0;
|
|
205
|
+
bottom: 0;
|
|
206
|
+
min-width: 3px;
|
|
207
|
+
background: #2d7d46;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.timeline-bar.status-error,
|
|
211
|
+
.timeline-bar.status-blocked {
|
|
212
|
+
background: #b43b3b;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.approval-row {
|
|
216
|
+
display: grid;
|
|
217
|
+
grid-template-columns: minmax(260px, 1fr) 180px auto auto;
|
|
218
|
+
gap: 10px;
|
|
219
|
+
align-items: center;
|
|
220
|
+
padding: 10px 0;
|
|
221
|
+
border-bottom: 1px solid #e7ebf0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.button.secondary {
|
|
225
|
+
background: #fff;
|
|
226
|
+
color: #0c5a9e;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
input, select {
|
|
230
|
+
width: 100%;
|
|
231
|
+
min-height: 36px;
|
|
232
|
+
border: 1px solid #ccd5df;
|
|
233
|
+
border-radius: 6px;
|
|
234
|
+
padding: 7px 9px;
|
|
235
|
+
font: inherit;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.inline-form {
|
|
239
|
+
display: grid;
|
|
240
|
+
grid-template-columns: minmax(140px, 1fr) minmax(220px, 2fr) auto;
|
|
241
|
+
gap: 10px;
|
|
242
|
+
align-items: center;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.actions {
|
|
246
|
+
display: flex;
|
|
247
|
+
gap: 8px;
|
|
248
|
+
align-items: center;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.form-actions {
|
|
252
|
+
display: flex;
|
|
253
|
+
justify-content: flex-end;
|
|
254
|
+
margin-top: 14px;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.demo-grid {
|
|
258
|
+
display: grid;
|
|
259
|
+
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
|
260
|
+
gap: 12px;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.demo-grid form {
|
|
264
|
+
border: 1px solid #e1e6ec;
|
|
265
|
+
border-radius: 8px;
|
|
266
|
+
padding: 14px;
|
|
267
|
+
display: grid;
|
|
268
|
+
gap: 10px;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.status {
|
|
272
|
+
border-radius: 999px;
|
|
273
|
+
padding: 3px 8px;
|
|
274
|
+
font-size: 12px;
|
|
275
|
+
background: #edf1f5;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.status-paused {
|
|
279
|
+
background: #fff0d6;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.status-ended {
|
|
283
|
+
background: #eceff3;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.status-running {
|
|
287
|
+
background: #e8f2ff;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.status-ok {
|
|
291
|
+
background: #e7f6eb;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.status-error, .status-blocked {
|
|
295
|
+
background: #fde7e7;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.kv th {
|
|
299
|
+
width: 220px;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@media (max-width: 900px) {
|
|
303
|
+
body {
|
|
304
|
+
grid-template-columns: 1fr;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.sidebar {
|
|
308
|
+
position: static;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.metric-grid {
|
|
312
|
+
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.page-head, .actions {
|
|
316
|
+
align-items: flex-start;
|
|
317
|
+
flex-direction: column;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.inline-form {
|
|
321
|
+
grid-template-columns: 1fr;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.approval-row {
|
|
325
|
+
grid-template-columns: 1fr;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Agents · Agent Loop Guard{% endblock %}
|
|
3
|
+
{% block content %}
|
|
4
|
+
<header class="page-head"><h1>Agents</h1></header>
|
|
5
|
+
{% if created_key %}
|
|
6
|
+
<section class="notice">
|
|
7
|
+
<strong>{{ created_agent.name }}</strong>
|
|
8
|
+
<code>{{ created_key }}</code>
|
|
9
|
+
</section>
|
|
10
|
+
{% endif %}
|
|
11
|
+
<section class="panel">
|
|
12
|
+
<h2>Create Agent Key</h2>
|
|
13
|
+
<form class="inline-form" action="/agents" method="post">
|
|
14
|
+
<input name="name" placeholder="Name" required>
|
|
15
|
+
<input name="project_id" value="default">
|
|
16
|
+
<select name="protocol">
|
|
17
|
+
<option value="openai">openai</option>
|
|
18
|
+
<option value="anthropic">anthropic</option>
|
|
19
|
+
</select>
|
|
20
|
+
<button type="submit">Create</button>
|
|
21
|
+
</form>
|
|
22
|
+
</section>
|
|
23
|
+
<section class="panel">
|
|
24
|
+
<h2>Local Keys</h2>
|
|
25
|
+
<table>
|
|
26
|
+
<thead><tr><th>ID</th><th>Name</th><th>Project</th><th>Protocol</th><th>State</th><th></th></tr></thead>
|
|
27
|
+
<tbody>
|
|
28
|
+
{% for agent in agents %}
|
|
29
|
+
<tr>
|
|
30
|
+
<td><code>{{ agent.id }}</code></td>
|
|
31
|
+
<td>{{ agent.name }}</td>
|
|
32
|
+
<td>{{ agent.project_id }}</td>
|
|
33
|
+
<td>{{ agent.protocol }}</td>
|
|
34
|
+
<td>{% if agent.paused %}paused{% elif agent.enabled %}enabled{% else %}disabled{% endif %}</td>
|
|
35
|
+
<td>
|
|
36
|
+
{% if agent.paused %}
|
|
37
|
+
<form action="/agents/{{ agent.id }}/resume" method="post"><button type="submit">Resume</button></form>
|
|
38
|
+
{% else %}
|
|
39
|
+
<form action="/agents/{{ agent.id }}/pause" method="post"><button type="submit">Pause</button></form>
|
|
40
|
+
{% endif %}
|
|
41
|
+
</td>
|
|
42
|
+
</tr>
|
|
43
|
+
{% endfor %}
|
|
44
|
+
</tbody>
|
|
45
|
+
</table>
|
|
46
|
+
</section>
|
|
47
|
+
{% endblock %}
|
|
48
|
+
|
app/templates/base.html
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{% block title %}Agent Loop Guard{% endblock %}</title>
|
|
7
|
+
<link rel="stylesheet" href="/static/styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<aside class="sidebar">
|
|
11
|
+
<a class="brand" href="/">Agent Loop Guard</a>
|
|
12
|
+
<nav>
|
|
13
|
+
<a href="/">Dashboard</a>
|
|
14
|
+
<a href="/sessions">Sessions</a>
|
|
15
|
+
<a href="/replay">Replay</a>
|
|
16
|
+
<a href="/mcp">MCP Firewall</a>
|
|
17
|
+
<a href="/demo">Demo Lab</a>
|
|
18
|
+
<a href="/policies">Policies</a>
|
|
19
|
+
<a href="/agents">Agents</a>
|
|
20
|
+
<a href="/settings">Settings</a>
|
|
21
|
+
</nav>
|
|
22
|
+
</aside>
|
|
23
|
+
<main class="content">
|
|
24
|
+
{% block content %}{% endblock %}
|
|
25
|
+
</main>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|