Cordon Deploy commited on
Commit
28a3709
Β·
1 Parent(s): b6d420e

deploy: sync cordon-cloud server + dashboard

Browse files
Files changed (5) hide show
  1. Dockerfile +3 -3
  2. app.py +125 -6
  3. static/policies.js +313 -0
  4. storage.py +122 -0
  5. templates/dashboard.html +115 -0
Dockerfile CHANGED
@@ -6,8 +6,8 @@
6
  # Dockerfile β€” this file
7
  # app.py β€” cloud_server/app.py copied at deploy time
8
  # storage.py β€” cloud_server/storage.py copied at deploy time
9
- # templates/ β€” dashboard.html
10
- # static/ β€” dashboard.js
11
  #
12
  # The Space's $HOME is writable for user 1000, so the sqlite db lives at
13
  # /home/app/data/cordon_cloud.db. Note that this filesystem is ephemeral:
@@ -35,7 +35,7 @@ RUN pip install \
35
  "uvicorn[standard]>=0.27,<1.0" \
36
  "pydantic>=2.5,<3.0" \
37
  "psycopg[binary,pool]>=3.2,<4.0" \
38
- "cordon-ai==0.2.1"
39
 
40
  # Lay the Python module out as ``cloud_server`` so the imports in
41
  # ``app.py`` (``from cloud_server.storage import EventStore``) resolve
 
6
  # Dockerfile β€” this file
7
  # app.py β€” cloud_server/app.py copied at deploy time
8
  # storage.py β€” cloud_server/storage.py copied at deploy time
9
+ # templates/ β€” dashboard.html, landing.html
10
+ # static/ β€” dashboard.js, policies.js
11
  #
12
  # The Space's $HOME is writable for user 1000, so the sqlite db lives at
13
  # /home/app/data/cordon_cloud.db. Note that this filesystem is ephemeral:
 
35
  "uvicorn[standard]>=0.27,<1.0" \
36
  "pydantic>=2.5,<3.0" \
37
  "psycopg[binary,pool]>=3.2,<4.0" \
38
+ "cordon-ai==0.2.2"
39
 
40
  # Lay the Python module out as ``cloud_server`` so the imports in
41
  # ``app.py`` (``from cloud_server.storage import EventStore``) resolve
app.py CHANGED
@@ -405,11 +405,19 @@ class TryRequest(BaseModel):
405
 
406
  Kept deliberately small so the surface stays clearly demo-only;
407
  the real SDK exposes the full :class:`cordon.Action` shape.
 
 
 
 
408
  """
409
  kind: str = Field(default="shell", description="'shell' or 'write_file'")
410
  command: str | None = Field(default=None, max_length=4096)
411
  changes: dict[str, str] | None = Field(default=None)
412
  profile: str = Field(default="strict", description="'strict' | 'default' | 'permissive'")
 
 
 
 
413
 
414
 
415
  _TRY_RL_WINDOW_S = 60.0
@@ -467,13 +475,32 @@ def try_probe(payload: TryRequest, request: Request) -> dict[str, Any]:
467
  detail=f"cordon SDK not installed in this Space: {exc}",
468
  ) from exc
469
 
470
- profile = (payload.profile or "strict").lower()
471
- if profile == "strict":
472
- guard = Guard.strict()
473
- elif profile == "permissive":
474
- guard = Guard.permissive()
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  else:
476
- guard = Guard.default()
 
 
 
 
 
 
477
 
478
  # Map the request to an Action. Bound by Pydantic's max_length so
479
  # we don't accept arbitrarily large payloads on a public endpoint.
@@ -508,6 +535,98 @@ def try_probe(payload: TryRequest, request: Request) -> dict[str, Any]:
508
  }
509
 
510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  @app.get("/v1/events")
512
  def list_events(
513
  request: Request,
 
405
 
406
  Kept deliberately small so the surface stays clearly demo-only;
407
  the real SDK exposes the full :class:`cordon.Action` shape.
408
+
409
+ Lane 4: optional ``policy`` field lets the dashboard policy editor
410
+ test a proposed policy against an action *before* the operator
411
+ hits Save. When ``policy`` is non-empty it overrides ``profile``.
412
  """
413
  kind: str = Field(default="shell", description="'shell' or 'write_file'")
414
  command: str | None = Field(default=None, max_length=4096)
415
  changes: dict[str, str] | None = Field(default=None)
416
  profile: str = Field(default="strict", description="'strict' | 'default' | 'permissive'")
417
+ policy: str | None = Field(
418
+ default=None, max_length=64 * 1024,
419
+ description="Optional inline policy DSL; overrides `profile` when set.",
420
+ )
421
 
422
 
423
  _TRY_RL_WINDOW_S = 60.0
 
475
  detail=f"cordon SDK not installed in this Space: {exc}",
476
  ) from exc
477
 
478
+ # Two paths: inline policy text (overrides everything) or a bare
479
+ # profile name. The inline-policy path is what the dashboard
480
+ # editor calls when previewing a draft.
481
+ if payload.policy and payload.policy.strip():
482
+ try:
483
+ guard = Guard.from_policy(payload.policy)
484
+ except Exception as exc: # noqa: BLE001 β€” surface to the UI
485
+ raise HTTPException(
486
+ status_code=400,
487
+ detail={
488
+ "message": str(exc),
489
+ "line": getattr(exc, "line", 0),
490
+ "snippet": getattr(exc, "snippet", ""),
491
+ },
492
+ ) from exc
493
+ # Surface to the response payload so callers can confirm the
494
+ # try-call did exercise the policy path, not a default profile.
495
+ profile = guard.name
496
  else:
497
+ profile = (payload.profile or "strict").lower()
498
+ if profile == "strict":
499
+ guard = Guard.strict()
500
+ elif profile == "permissive":
501
+ guard = Guard.permissive()
502
+ else:
503
+ guard = Guard.default()
504
 
505
  # Map the request to an Action. Bound by Pydantic's max_length so
506
  # we don't accept arbitrarily large payloads on a public endpoint.
 
535
  }
536
 
537
 
538
+ # ─── Per-project policy endpoints (Lane 4) ────────────────────────────────────
539
+
540
+
541
+ class PutPolicyRequest(BaseModel):
542
+ """Body for ``PUT /v1/policies/{project}``."""
543
+ text: str = Field(..., min_length=0, max_length=64 * 1024)
544
+
545
+
546
+ class ValidatePolicyRequest(BaseModel):
547
+ """Body for ``POST /v1/policies/validate``."""
548
+ text: str = Field(..., max_length=64 * 1024)
549
+
550
+
551
+ @app.get("/v1/policies/{project}")
552
+ def get_policy(project: str, request: Request) -> dict[str, Any]:
553
+ """Return the current policy for a project, or 404 if none set.
554
+
555
+ Read surface β€” gated by the dashboard token, same as ``/v1/events``.
556
+ """
557
+ _require_dashboard_access(request)
558
+ row = STORE.get_policy(project)
559
+ if row is None:
560
+ raise HTTPException(status_code=404, detail="no policy configured for project")
561
+ return row
562
+
563
+
564
+ @app.put("/v1/policies/{project}")
565
+ def put_policy(
566
+ project: str,
567
+ payload: PutPolicyRequest,
568
+ request: Request,
569
+ ) -> dict[str, Any]:
570
+ """Upsert the policy for a project. Validates the DSL before storing.
571
+
572
+ Write surface β€” gated by the dashboard token, intentionally NOT by
573
+ the ingest key (writing policies is a *control-plane* action, not
574
+ a data-plane action, so it shouldn't share credentials with the
575
+ agent SDK).
576
+ """
577
+ _require_dashboard_access(request)
578
+
579
+ # Validate before storing β€” never persist a policy that won't load.
580
+ try:
581
+ from cordon.policy import parse as parse_policy
582
+ parse_policy(payload.text)
583
+ except Exception as exc: # noqa: BLE001 β€” surface line+snippet to the editor
584
+ line = getattr(exc, "line", 0)
585
+ snippet = getattr(exc, "snippet", "")
586
+ raise HTTPException(
587
+ status_code=400,
588
+ detail={
589
+ "message": str(exc),
590
+ "line": line,
591
+ "snippet": snippet,
592
+ },
593
+ ) from exc
594
+
595
+ return STORE.put_policy(project, payload.text)
596
+
597
+
598
+ @app.delete("/v1/policies/{project}")
599
+ def delete_policy(project: str, request: Request) -> dict[str, Any]:
600
+ _require_dashboard_access(request)
601
+ removed = STORE.delete_policy(project)
602
+ return {"project": project, "removed": removed}
603
+
604
+
605
+ @app.post("/v1/policies/validate")
606
+ def validate_policy(payload: ValidatePolicyRequest) -> dict[str, Any]:
607
+ """Parse the supplied text and report errors. Public, rate-limited.
608
+
609
+ Drives the live syntax-check in the dashboard editor β€” we don't
610
+ want every keystroke to hit ``PUT`` and clobber the saved version.
611
+ No persistence; the storage layer never sees this text.
612
+ """
613
+ try:
614
+ from cordon.policy import parse as parse_policy
615
+ policy = parse_policy(payload.text)
616
+ except Exception as exc: # noqa: BLE001
617
+ return {
618
+ "ok": False,
619
+ "message": str(exc),
620
+ "line": getattr(exc, "line", 0),
621
+ "snippet": getattr(exc, "snippet", ""),
622
+ }
623
+ return {
624
+ "ok": True,
625
+ "profile": policy.profile,
626
+ "n_rules": len(policy.rules),
627
+ }
628
+
629
+
630
  @app.get("/v1/events")
631
  def list_events(
632
  request: Request,
static/policies.js ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Cordon Cloud β€” Policy editor (Lane 4).
2
+ *
3
+ * Self-contained, no framework. Drives the #policies section of
4
+ * dashboard.html:
5
+ *
6
+ * - Loads the project's saved policy on mount (GET /v1/policies/{p}).
7
+ * - Validates as the operator types (debounced POST /v1/policies/validate).
8
+ * - Save β†’ PUT /v1/policies/{p}
9
+ * - Try β†’ POST /v1/try with the editor's current text inline
10
+ * - Revert β†’ re-fetch the saved version
11
+ * - Delete β†’ DELETE /v1/policies/{p} (with a confirm)
12
+ *
13
+ * The dashboard token is read from the URL once (via the same
14
+ * convention as dashboard.js) and forwarded as ``?t=`` on every
15
+ * authed request. We don't rely on cookies β€” the magic-link flow is
16
+ * the only auth surface and it lives entirely in the URL.
17
+ *
18
+ * Why a separate file: keeps the policy code reviewable on its own,
19
+ * and lets us A/B-deploy the editor without touching the well-tested
20
+ * verdict-stream code path.
21
+ */
22
+
23
+ (() => {
24
+ "use strict";
25
+
26
+ const $ = (id) => document.getElementById(id);
27
+
28
+ // The editor only exists on the dashboard. Bail quietly if loaded
29
+ // somewhere else (defensive: the same script gets bundled in the
30
+ // landing template too, by mistake or by future intent).
31
+ const editor = $("pol-editor");
32
+ if (!editor) return;
33
+
34
+ const status = $("pol-status");
35
+ const meta = $("pol-meta");
36
+ const btnVal = $("pol-validate");
37
+ const btnSave = $("pol-save");
38
+ const btnRev = $("pol-revert");
39
+ const btnDel = $("pol-delete");
40
+ const tryKind = $("pol-try-kind");
41
+ const tryCmd = $("pol-try-cmd");
42
+ const btnTry = $("pol-try-run");
43
+ const tryOut = $("pol-try-result");
44
+
45
+ const TOKEN = new URLSearchParams(location.search).get("t") || "";
46
+
47
+ /** Build a relative URL with the access token forwarded. */
48
+ function url(path) {
49
+ const u = new URL(path, location.origin);
50
+ if (TOKEN) u.searchParams.set("t", TOKEN);
51
+ return u.pathname + u.search;
52
+ }
53
+
54
+ /** Read the project from the dashboard's project-select; falls back to "demo". */
55
+ function project() {
56
+ const sel = document.getElementById("project-select");
57
+ return (sel && sel.value) || "demo";
58
+ }
59
+
60
+ // ─── Status line helpers ──────────────────────────────────────────
61
+
62
+ function setStatus(msg, kind = "info") {
63
+ const colors = {
64
+ info: "text-neutral-500",
65
+ ok: "text-emerald-400",
66
+ warn: "text-yellow-300",
67
+ error: "text-red-400",
68
+ };
69
+ status.className =
70
+ "mono text-xs px-5 py-2 border-t border-white/5 min-h-[34px] " +
71
+ (colors[kind] || colors.info);
72
+ status.textContent = msg;
73
+ }
74
+
75
+ function setMeta(text) {
76
+ meta.textContent = text || "";
77
+ }
78
+
79
+ // ─── Saved-state tracking ─────────────────────────────────────────
80
+
81
+ // The last text we know matches the server. Used by Revert + by the
82
+ // dirty-indicator on the Save button.
83
+ let savedText = "";
84
+ let savedVersion = null;
85
+
86
+ function applyServerState(payload) {
87
+ savedText = payload.text || "";
88
+ savedVersion = payload.version ?? null;
89
+ editor.value = savedText;
90
+ if (savedVersion != null) {
91
+ const ts = new Date((payload.updated_at || 0) * 1000);
92
+ setMeta(`v${savedVersion} Β· saved ${ts.toLocaleString()}`);
93
+ } else {
94
+ setMeta("");
95
+ }
96
+ refreshDirty();
97
+ }
98
+
99
+ function refreshDirty() {
100
+ const dirty = editor.value !== savedText;
101
+ btnSave.classList.toggle("opacity-50", !dirty);
102
+ btnSave.disabled = !dirty;
103
+ btnRev.classList.toggle("opacity-50", !dirty);
104
+ btnRev.disabled = !dirty;
105
+ }
106
+
107
+ // ─── Initial load ─────────────────────────────────────────────────
108
+
109
+ async function loadCurrentPolicy() {
110
+ setStatus("loading current policy…");
111
+ try {
112
+ const r = await fetch(url(`/v1/policies/${encodeURIComponent(project())}`));
113
+ if (r.status === 404) {
114
+ applyServerState({ text: "", version: null, updated_at: 0 });
115
+ setStatus("no policy set yet β€” type one in and click save.", "info");
116
+ return;
117
+ }
118
+ if (!r.ok) {
119
+ setStatus(`load failed: HTTP ${r.status}`, "error");
120
+ return;
121
+ }
122
+ const body = await r.json();
123
+ applyServerState(body);
124
+ setStatus("loaded.", "ok");
125
+ } catch (err) {
126
+ setStatus(`load error: ${err.message}`, "error");
127
+ }
128
+ }
129
+
130
+ // ─── Validate (debounced + on-demand) ─────────────────────────────
131
+
132
+ let validateTimer = 0;
133
+
134
+ async function validateNow({ silent = false } = {}) {
135
+ const text = editor.value;
136
+ if (!text.trim()) {
137
+ if (!silent) setStatus("editor is empty.", "warn");
138
+ return;
139
+ }
140
+ try {
141
+ const r = await fetch(url("/v1/policies/validate"), {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json" },
144
+ body: JSON.stringify({ text }),
145
+ });
146
+ const body = await r.json();
147
+ if (body.ok) {
148
+ setStatus(
149
+ `valid β€” profile: ${body.profile}, ${body.n_rules} rule(s).`,
150
+ "ok",
151
+ );
152
+ } else {
153
+ setStatus(`line ${body.line}: ${body.message}`, "error");
154
+ }
155
+ } catch (err) {
156
+ setStatus(`validate failed: ${err.message}`, "error");
157
+ }
158
+ }
159
+
160
+ editor.addEventListener("input", () => {
161
+ refreshDirty();
162
+ clearTimeout(validateTimer);
163
+ // Debounce: 500 ms after the operator stops typing β€” typing-fast
164
+ // operators get a single round-trip per pause, not per keystroke.
165
+ validateTimer = setTimeout(() => validateNow({ silent: true }), 500);
166
+ });
167
+
168
+ btnVal.addEventListener("click", () => validateNow({ silent: false }));
169
+
170
+ // ─── Save ─────────────────────────────────────────────────────────
171
+
172
+ btnSave.addEventListener("click", async () => {
173
+ const text = editor.value;
174
+ setStatus("saving…");
175
+ try {
176
+ const r = await fetch(url(`/v1/policies/${encodeURIComponent(project())}`), {
177
+ method: "PUT",
178
+ headers: { "Content-Type": "application/json" },
179
+ body: JSON.stringify({ text }),
180
+ });
181
+ if (r.status === 401) {
182
+ setStatus(
183
+ "401 β€” open the dashboard via the magic-link URL with `?t=<token>`.",
184
+ "error",
185
+ );
186
+ return;
187
+ }
188
+ if (!r.ok) {
189
+ // The server returns {detail: {message, line, snippet}} for
190
+ // syntax errors. Show the line number so it's actionable.
191
+ const body = await r.json().catch(() => ({}));
192
+ const d = body.detail || {};
193
+ setStatus(
194
+ d.line
195
+ ? `line ${d.line}: ${d.message}`
196
+ : `save failed: HTTP ${r.status}`,
197
+ "error",
198
+ );
199
+ return;
200
+ }
201
+ const body = await r.json();
202
+ applyServerState(body);
203
+ setStatus(`saved as v${body.version}.`, "ok");
204
+ } catch (err) {
205
+ setStatus(`save error: ${err.message}`, "error");
206
+ }
207
+ });
208
+
209
+ // ─── Revert ───────────────────────────────────────────────────────
210
+
211
+ btnRev.addEventListener("click", () => {
212
+ editor.value = savedText;
213
+ refreshDirty();
214
+ setStatus(savedVersion ? `reverted to v${savedVersion}.` : "reverted.", "info");
215
+ });
216
+
217
+ // ─── Delete ───────────────────────────────────────────────────────
218
+
219
+ btnDel.addEventListener("click", async () => {
220
+ if (!confirm(`Delete policy for project "${project()}"? Agents will fall back to the strict default.`)) return;
221
+ setStatus("deleting…");
222
+ try {
223
+ const r = await fetch(url(`/v1/policies/${encodeURIComponent(project())}`), {
224
+ method: "DELETE",
225
+ });
226
+ if (!r.ok) {
227
+ setStatus(`delete failed: HTTP ${r.status}`, "error");
228
+ return;
229
+ }
230
+ const body = await r.json();
231
+ applyServerState({ text: "", version: null, updated_at: 0 });
232
+ setStatus(
233
+ body.removed ? "deleted." : "no policy was set; nothing to delete.",
234
+ "info",
235
+ );
236
+ } catch (err) {
237
+ setStatus(`delete error: ${err.message}`, "error");
238
+ }
239
+ });
240
+
241
+ // ─── Try this policy ──────────────────────────────────────────────
242
+
243
+ function renderTry(body) {
244
+ const decision = (body.decision || "?").toLowerCase();
245
+ const colors = {
246
+ block: "text-red-300 bg-red-500/10 border-red-500/30",
247
+ flag: "text-yellow-300 bg-yellow-500/10 border-yellow-500/30",
248
+ allow: "text-emerald-300 bg-emerald-500/10 border-emerald-500/30",
249
+ };
250
+ const cls = colors[decision] || "text-neutral-300 border-white/10 bg-white/5";
251
+
252
+ const probes = (body.probes || []).map(
253
+ (p) => `<div class="text-neutral-500">β†’ ${escapeHtml(p.probe)} (${escapeHtml(p.severity)}) Β· ${escapeHtml(p.evidence || "")}</div>`,
254
+ ).join("");
255
+
256
+ const reason = body.reason ? `<div class="text-neutral-400 mt-1">${escapeHtml(body.reason)}</div>` : "";
257
+
258
+ tryOut.innerHTML = `
259
+ <div class="px-2 py-1 rounded border ${cls} inline-block uppercase tracking-wider">${decision}</div>
260
+ <div class="text-neutral-500">profile: <span class="text-neutral-300">${escapeHtml(body.profile || "?")}</span></div>
261
+ ${reason}
262
+ ${probes}
263
+ `;
264
+ }
265
+
266
+ function escapeHtml(s) {
267
+ return String(s).replace(/[&<>"']/g, (c) => ({
268
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
269
+ }[c]));
270
+ }
271
+
272
+ btnTry.addEventListener("click", async () => {
273
+ const cmd = (tryCmd.value || "").trim();
274
+ if (!cmd) {
275
+ tryOut.innerHTML = '<div class="text-yellow-300">enter a command first.</div>';
276
+ return;
277
+ }
278
+ tryOut.innerHTML = '<div class="text-neutral-500">running probes…</div>';
279
+ try {
280
+ const r = await fetch(url("/v1/try"), {
281
+ method: "POST",
282
+ headers: { "Content-Type": "application/json" },
283
+ body: JSON.stringify({
284
+ kind: tryKind.value || "shell",
285
+ command: cmd,
286
+ policy: editor.value,
287
+ }),
288
+ });
289
+ if (!r.ok) {
290
+ const body = await r.json().catch(() => ({}));
291
+ const d = body.detail || {};
292
+ tryOut.innerHTML =
293
+ `<div class="text-red-300">${
294
+ d.line ? `line ${d.line}: ${escapeHtml(d.message || "")}` : `error: HTTP ${r.status}`
295
+ }</div>`;
296
+ return;
297
+ }
298
+ renderTry(await r.json());
299
+ } catch (err) {
300
+ tryOut.innerHTML =
301
+ `<div class="text-red-300">${escapeHtml(err.message)}</div>`;
302
+ }
303
+ });
304
+
305
+ // ─── Boot ────────────────────────────────────────────────────────
306
+
307
+ // Reload the saved policy if the operator switches projects via the
308
+ // dashboard's project-select. Same convention dashboard.js uses.
309
+ const projSel = document.getElementById("project-select");
310
+ if (projSel) projSel.addEventListener("change", loadCurrentPolicy);
311
+
312
+ loadCurrentPolicy();
313
+ })();
storage.py CHANGED
@@ -90,6 +90,15 @@ class EventStore(Protocol):
90
  def count(self, project: str | None = None) -> int: ...
91
  def close(self) -> None: ...
92
 
 
 
 
 
 
 
 
 
 
93
 
94
  class SqliteEventStore:
95
  """Single-file sqlite backend. The default.
@@ -141,6 +150,13 @@ class SqliteEventStore:
141
  ON events (project, blocked, ts DESC);
142
  CREATE INDEX IF NOT EXISTS idx_events_probe
143
  ON events (project, top_probe, ts DESC);
 
 
 
 
 
 
 
144
  """
145
  )
146
 
@@ -325,6 +341,58 @@ class SqliteEventStore:
325
  ).fetchone()
326
  return int(row["n"] or 0)
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  def close(self) -> None:
329
  with self._lock:
330
  self._conn.close()
@@ -488,6 +556,13 @@ class PostgresEventStore:
488
  ON events (project, blocked, ts DESC);
489
  CREATE INDEX IF NOT EXISTS idx_events_probe
490
  ON events (project, top_probe, ts DESC);
 
 
 
 
 
 
 
491
  """
492
 
493
  def _ensure_schema(self) -> None:
@@ -650,6 +725,53 @@ class PostgresEventStore:
650
  row = cur.fetchone() or {}
651
  return int(row.get("n") or 0)
652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  def close(self) -> None:
654
  self._pool.close()
655
 
 
90
  def count(self, project: str | None = None) -> int: ...
91
  def close(self) -> None: ...
92
 
93
+ # ── Per-project policy storage (Lane 4) ────────────────────────────────
94
+ # Stored as the verbatim policy text plus a monotonic version + an
95
+ # updated_at timestamp. Returns None when no policy has ever been
96
+ # configured for the project β€” callers fall back to whichever
97
+ # default Guard profile they prefer.
98
+ def get_policy(self, project: str) -> dict[str, Any] | None: ...
99
+ def put_policy(self, project: str, text: str) -> dict[str, Any]: ...
100
+ def delete_policy(self, project: str) -> bool: ...
101
+
102
 
103
  class SqliteEventStore:
104
  """Single-file sqlite backend. The default.
 
150
  ON events (project, blocked, ts DESC);
151
  CREATE INDEX IF NOT EXISTS idx_events_probe
152
  ON events (project, top_probe, ts DESC);
153
+
154
+ CREATE TABLE IF NOT EXISTS policies (
155
+ project TEXT PRIMARY KEY,
156
+ text TEXT NOT NULL,
157
+ version INTEGER NOT NULL DEFAULT 1,
158
+ updated_at REAL NOT NULL
159
+ );
160
  """
161
  )
162
 
 
341
  ).fetchone()
342
  return int(row["n"] or 0)
343
 
344
+ # ─── Policy storage ───────────────────────────────────────────────────────
345
+
346
+ def get_policy(self, project: str) -> dict[str, Any] | None:
347
+ """Return ``{text, version, updated_at}`` or ``None`` if unset."""
348
+ with self._lock:
349
+ row = self._conn.execute(
350
+ "SELECT text, version, updated_at FROM policies WHERE project = ?",
351
+ (project,),
352
+ ).fetchone()
353
+ if row is None:
354
+ return None
355
+ return {
356
+ "project": project,
357
+ "text": row["text"],
358
+ "version": int(row["version"]),
359
+ "updated_at": float(row["updated_at"]),
360
+ }
361
+
362
+ def put_policy(self, project: str, text: str) -> dict[str, Any]:
363
+ """Upsert the policy for ``project``; auto-increments ``version``."""
364
+ now = time.time()
365
+ with self._lock:
366
+ self._conn.execute(
367
+ """
368
+ INSERT INTO policies (project, text, version, updated_at)
369
+ VALUES (?, ?, 1, ?)
370
+ ON CONFLICT(project) DO UPDATE SET
371
+ text = excluded.text,
372
+ version = policies.version + 1,
373
+ updated_at = excluded.updated_at
374
+ """,
375
+ (project, text, now),
376
+ )
377
+ row = self._conn.execute(
378
+ "SELECT version, updated_at FROM policies WHERE project = ?",
379
+ (project,),
380
+ ).fetchone()
381
+ return {
382
+ "project": project,
383
+ "text": text,
384
+ "version": int(row["version"]),
385
+ "updated_at": float(row["updated_at"]),
386
+ }
387
+
388
+ def delete_policy(self, project: str) -> bool:
389
+ """Delete the policy for ``project``. Returns True if a row was removed."""
390
+ with self._lock:
391
+ cur = self._conn.execute(
392
+ "DELETE FROM policies WHERE project = ?", (project,),
393
+ )
394
+ return cur.rowcount > 0
395
+
396
  def close(self) -> None:
397
  with self._lock:
398
  self._conn.close()
 
556
  ON events (project, blocked, ts DESC);
557
  CREATE INDEX IF NOT EXISTS idx_events_probe
558
  ON events (project, top_probe, ts DESC);
559
+
560
+ CREATE TABLE IF NOT EXISTS policies (
561
+ project TEXT PRIMARY KEY,
562
+ text TEXT NOT NULL,
563
+ version INTEGER NOT NULL DEFAULT 1,
564
+ updated_at DOUBLE PRECISION NOT NULL
565
+ );
566
  """
567
 
568
  def _ensure_schema(self) -> None:
 
725
  row = cur.fetchone() or {}
726
  return int(row.get("n") or 0)
727
 
728
+ # ─── Policy storage ───────────────────────────────────────────────────
729
+
730
+ def get_policy(self, project: str) -> dict[str, Any] | None:
731
+ with self._pool.connection() as conn, conn.cursor() as cur:
732
+ cur.execute(
733
+ "SELECT text, version, updated_at FROM policies WHERE project = %s",
734
+ (project,),
735
+ )
736
+ row = cur.fetchone()
737
+ if row is None:
738
+ return None
739
+ return {
740
+ "project": project,
741
+ "text": row["text"],
742
+ "version": int(row["version"]),
743
+ "updated_at": float(row["updated_at"]),
744
+ }
745
+
746
+ def put_policy(self, project: str, text: str) -> dict[str, Any]:
747
+ now = time.time()
748
+ with self._pool.connection() as conn, conn.cursor() as cur:
749
+ cur.execute(
750
+ """
751
+ INSERT INTO policies (project, text, version, updated_at)
752
+ VALUES (%s, %s, 1, %s)
753
+ ON CONFLICT (project) DO UPDATE SET
754
+ text = EXCLUDED.text,
755
+ version = policies.version + 1,
756
+ updated_at = EXCLUDED.updated_at
757
+ RETURNING version, updated_at
758
+ """,
759
+ (project, text, now),
760
+ )
761
+ row = cur.fetchone() or {}
762
+ return {
763
+ "project": project,
764
+ "text": text,
765
+ "version": int(row.get("version") or 1),
766
+ "updated_at": float(row.get("updated_at") or now),
767
+ }
768
+
769
+ def delete_policy(self, project: str) -> bool:
770
+ with self._pool.connection() as conn, conn.cursor() as cur:
771
+ cur.execute("DELETE FROM policies WHERE project = %s", (project,))
772
+ n = cur.rowcount
773
+ return n > 0
774
+
775
  def close(self) -> None:
776
  self._pool.close()
777
 
templates/dashboard.html CHANGED
@@ -88,6 +88,8 @@
88
  <option value="86400" selected>last 24 h</option>
89
  <option value="604800">last 7 d</option>
90
  </select>
 
 
91
  <a href="/api/docs" target="_blank" rel="noopener"
92
  class="hover:text-white transition-colors hidden md:inline">API</a>
93
  <a href="https://github.com/Ashok-kumar290/cordon" target="_blank" rel="noopener"
@@ -183,6 +185,118 @@
183
  </section>
184
 
185
  <!-- ─── How to send your own events ────────────────────────────── -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  <section class="max-w-7xl mx-auto px-6 mt-10 mb-10">
187
  <div class="panel rounded-lg p-6">
188
  <h2 class="text-lg font-semibold text-white">Ship your own verdicts here</h2>
@@ -232,5 +346,6 @@ guard.add_listener(CloudReporter(
232
  </footer>
233
 
234
  <script src="/static/dashboard.js"></script>
 
235
  </body>
236
  </html>
 
88
  <option value="86400" selected>last 24 h</option>
89
  <option value="604800">last 7 d</option>
90
  </select>
91
+ <a href="#policies"
92
+ class="hover:text-white transition-colors hidden md:inline">Policies</a>
93
  <a href="/api/docs" target="_blank" rel="noopener"
94
  class="hover:text-white transition-colors hidden md:inline">API</a>
95
  <a href="https://github.com/Ashok-kumar290/cordon" target="_blank" rel="noopener"
 
185
  </section>
186
 
187
  <!-- ─── How to send your own events ────────────────────────────── -->
188
+ <!-- ─── Policy editor (Lane 4) ─────────────────────────────────── -->
189
+ <section id="policies" class="max-w-7xl mx-auto px-6 mt-10 scroll-mt-20">
190
+ <div class="panel rounded-lg overflow-hidden">
191
+
192
+ <!-- Header strip ─ identifies the project + carries action buttons -->
193
+ <div class="flex flex-wrap items-center gap-3 px-5 py-3 border-b border-white/5">
194
+ <div class="flex items-center gap-2">
195
+ <iconify-icon icon="ph:shield-check-bold" width="18"
196
+ class="text-emerald-400"></iconify-icon>
197
+ <div class="text-sm font-medium text-white">Project policy</div>
198
+ <span id="pol-meta" class="mono text-xs text-neutral-500"></span>
199
+ </div>
200
+
201
+ <div class="ml-auto flex items-center gap-2">
202
+ <button id="pol-validate"
203
+ class="px-3 py-1 text-xs rounded mono border border-white/10
204
+ text-neutral-300 hover:text-white hover:border-white/30
205
+ transition-colors">
206
+ validate
207
+ </button>
208
+ <button id="pol-save"
209
+ class="px-3 py-1 text-xs rounded mono bg-white text-black
210
+ hover:bg-neutral-200 transition-colors">
211
+ save
212
+ </button>
213
+ <button id="pol-revert"
214
+ class="px-3 py-1 text-xs rounded mono border border-white/10
215
+ text-neutral-300 hover:text-white hover:border-white/30
216
+ transition-colors">
217
+ revert
218
+ </button>
219
+ <button id="pol-delete"
220
+ class="px-3 py-1 text-xs rounded mono border border-red-500/30
221
+ text-red-300 hover:text-red-200 hover:border-red-500/60
222
+ transition-colors">
223
+ delete
224
+ </button>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- Two-column body: editor on the left, "try it" on the right -->
229
+ <div class="grid grid-cols-1 lg:grid-cols-3">
230
+
231
+ <!-- Editor + status line -->
232
+ <div class="lg:col-span-2 border-b lg:border-b-0 lg:border-r border-white/5">
233
+ <textarea id="pol-editor"
234
+ spellcheck="false"
235
+ class="w-full h-[420px] block bg-black/40 text-neutral-200 mono text-[13px]
236
+ leading-6 px-5 py-4 outline-none resize-none scrollbar"
237
+ placeholder="profile: strict
238
+ &#10;
239
+ # Carve out a real-world need our agents have:
240
+ allow when command_starts_with: &quot;rm -rf node_modules&quot;
241
+ allow when command_starts_with: &quot;rm -rf ./build&quot;
242
+ &#10;
243
+ # Promote a known-risky pattern to a hard block:
244
+ block when path_starts_with: &quot;/etc/&quot;
245
+ &#10;
246
+ # Downgrade a noisy probe to flag-only:
247
+ flag when probe: typosquat"></textarea>
248
+
249
+ <div id="pol-status"
250
+ class="mono text-xs px-5 py-2 border-t border-white/5
251
+ text-neutral-500 min-h-[34px]">
252
+ ready.
253
+ </div>
254
+ </div>
255
+
256
+ <!-- Try-it panel: run the draft policy against an action without saving -->
257
+ <div class="p-5">
258
+ <div class="text-[11px] uppercase tracking-wider text-neutral-500">
259
+ Try this policy
260
+ </div>
261
+ <p class="mt-1 text-xs text-neutral-400">
262
+ Run the editor's current text against an action. Doesn't save β€”
263
+ useful for sanity-checking carve-outs before they go live.
264
+ </p>
265
+
266
+ <label class="block mt-4 text-[11px] uppercase tracking-wider text-neutral-500">
267
+ Action kind
268
+ </label>
269
+ <select id="pol-try-kind"
270
+ class="mt-1 w-full bg-transparent border border-white/10 rounded px-2 py-1.5
271
+ mono text-xs focus:outline-none focus:border-white/30">
272
+ <option value="shell">shell</option>
273
+ <option value="file">file</option>
274
+ </select>
275
+
276
+ <label class="block mt-3 text-[11px] uppercase tracking-wider text-neutral-500">
277
+ Command
278
+ </label>
279
+ <input id="pol-try-cmd" type="text"
280
+ spellcheck="false"
281
+ placeholder="rm -rf node_modules"
282
+ class="mt-1 w-full bg-black/40 border border-white/10 rounded px-2 py-1.5
283
+ mono text-xs text-neutral-200 focus:outline-none focus:border-white/30" />
284
+
285
+ <button id="pol-try-run"
286
+ class="mt-4 w-full px-3 py-2 text-xs rounded mono
287
+ bg-white text-black hover:bg-neutral-200 transition-colors">
288
+ run probes
289
+ </button>
290
+
291
+ <div id="pol-try-result"
292
+ class="mt-4 mono text-xs space-y-2 min-h-[120px]">
293
+ <div class="text-neutral-500">Output appears here.</div>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </section>
299
+
300
  <section class="max-w-7xl mx-auto px-6 mt-10 mb-10">
301
  <div class="panel rounded-lg p-6">
302
  <h2 class="text-lg font-semibold text-white">Ship your own verdicts here</h2>
 
346
  </footer>
347
 
348
  <script src="/static/dashboard.js"></script>
349
+ <script src="/static/policies.js"></script>
350
  </body>
351
  </html>