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
@@ -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
+
@@ -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>