Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,13 @@ tiers that need one, the code) back to the *existing* auth-challenge machinery r
decision path, which performs the real verification unchanged. The channel engages only while a
dashboard's liveness heartbeat is fresh (< 5s old); a stale or missing heartbeat, or an
unanswered approval, falls back to the next channel (MCP elicitation → GUI dialog → terminal)
with no added latency and no denial invented on the dashboard's behalf. Visual polish lands in
upcoming versions.
with no added latency and no denial invented on the dashboard's behalf.

**Visual polish.** Dark-by-default (a `prefers-color-scheme: light` override is available),
with color-coded PASS/AUTH/BLOCK and risk badges in the live feed and pending-approval cards, a
header bar showing the current mode + effective enforcement at a glance, and a designed empty
state before any decisions arrive — no build step, no external assets, works fully offline like
the rest of the shell.

### Try the demo

Expand Down Expand Up @@ -489,6 +494,7 @@ The adaptive layer's four SL5 "care" weights (`confidentiality`, `reversibility`
- ✅ **Turn gate (Feature 11):** a second, pre-inference invocation point (`doberman.turngate`) that judges the user's *turn* — prompt plus attached/pasted/tool-fetched content — before a single inference token is spent. Tier 0 deterministic signatures (instruction nullification, authority override, secret export, encoded payload) hard-block on an issue-vs-mention + origin discrimination (untrusted-origin match always blocks; a typed *mention* steps up instead of blocking); Tier 1 heuristic classes (embedded pasted instructions, persona override, obfuscation, urgency+secrecy framing) are AUTH-only and structurally block-incapable; a stylometric co-occurrence gate steps up only when an extreme per-entity style outlier coincides with a sensitive apparent intent, never on style alone; a repeat-after-block escape hatch scales the re-challenge to the original block (Tier 0 → 2FA) with single-use approval and a third-attempt lockout. Released turns tag-and-pass a bounded, raise-only `TurnContext` into the action stage (a flagged turn's follow-on actions score harsher; flagged pasted segments inherit `provenance: untrusted_data`). The turn gate is additive and an efficiency/early-warning layer only — the action gate above remains the safety guarantee, and with no host pre-inference hook or `DOBERMAN_TURN_GATE=off` it is simply absent.
- ✅ **Turn gate boundary:** the import-linter contract now forbids the policy core from importing **either** invocation adapter (`doberman.proxy`, `doberman.turngate`) — the turn gate stays a pure adapter, injected into the engine like the proxy, never a static dependency of it.
- ✅ Dashboard, interactive AUTH approve/deny (D3): a `pending_approvals` queue mediates between the decision path and the dashboard purely through SQLite — never HTTP into the decision path. `DashboardPrompter` implements the existing `Prompter` interface, engaging the queue only while a liveness heartbeat is fresh; a stale/missing heartbeat, an unanswered approval, or a poll timeout all fall back to the next channel (elicitation → GUI → terminal) with zero added latency and no denial invented on the dashboard's behalf. Resolution is a single-use, race-safe `UPDATE ... WHERE status='pending'` transition (a resolved/expired row 409s), and the dashboard only ever relays a decision (plus, for 2FA tiers, a code) — verification stays entirely in the decision-path process via the existing TOTP check. Pending rows carry only already-redacted fields (action type, risk, reason codes, explanation, path class) — never a raw target or secret
- ✅ Dashboard visual polish (D5): color-coded PASS/AUTH/BLOCK and risk badges (mirroring the terminal's `[RISK: <level>]` convention) in the live feed and pending-approval cards, a header bar surfacing the current mode + effective enforcement from the existing `/api/stats` fields, and a CSS-only designed empty state for both the feed and the pending-approvals list before any decisions arrive. The dark-by-default palette is formalized as CSS custom properties with a `prefers-color-scheme: light` override — still one inline shell, no build step, no new dependencies, no endpoint/auth/redaction changes
- 📋 Host-harness, continued (containment architecture): deeper Bash-command egress parsing · entropy-on-egress escalation · warm-daemon adaptive layer · honeytoken tripwire + session circuit-breaker
- 🛠 Cost observability — **CB.1 + CB.2 landed**: a redaction-safe `CostEvent` + local append-only meter (`doberman.storage.cost`), advisory and strictly off the decision path; plus a `CostObserver` plugin seam (`doberman.cost_observers` entry-point group) — observers receive a copy of every recorded event, are isolated (a raising observer is logged and skipped, never breaks the record path), and can never alter a verdict. Next: raise-only loop-anomaly detector (CB.3)
- 📋 Enterprise platform: centralized control plane, dashboards, org policy, SSO/RBAC
Expand Down
219 changes: 175 additions & 44 deletions src/doberman/dash/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@
decision-path side, which feeds it through the EXISTING
:func:`doberman.auth.totp.verify`.

D5 (polish) is a later slice - not built here.
D5 (polish) layers verdict/risk color badges, a mode + enforcement header bar,
and CSS-only empty states onto this same inline shell - the dark-by-default
palette is formalized as CSS custom properties. Still no build toolchain, no
new endpoints, no change to auth/redaction/decision-path behavior.
"""

from __future__ import annotations
Expand Down Expand Up @@ -82,65 +85,140 @@
<title>Doberman Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: dark light; }
:root {
color-scheme: dark light;
--bg: #0b0d10;
--surface: #14171c;
--border: #262b33;
--ink: #e6e6e6;
--ink-dim: #9aa1ac;
--mono: ui-monospace, "SF Mono", Consolas, monospace;
--pass: #3fb950;
--pass-bg: rgba(63, 185, 80, .14);
--auth: #d29922;
--auth-bg: rgba(210, 153, 34, .14);
--block: #f85149;
--block-bg: rgba(248, 81, 73, .14);
--neutral: #8b949e;
--neutral-bg: rgba(139, 148, 158, .14);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f7f7f8;
--surface: #ffffff;
--border: #dde1e6;
--ink: #111;
--ink-dim: #5b6572;
}
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2rem; min-height: 100vh; box-sizing: border-box;
margin: 0; padding: 2rem; min-height: 100vh;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0b0d10; color: #e6e6e6;
background: var(--bg); color: var(--ink);
}
@media (prefers-color-scheme: light) {
body { background: #f7f7f8; color: #111; }
h1 { font-size: 1.1rem; font-weight: 600; margin: 0; letter-spacing: -.01em; }
h2 { font-size: .9rem; font-weight: 600; margin: 1.75rem 0 .6rem; color: var(--ink-dim); }
.topbar {
display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1rem;
padding-bottom: 1rem; margin-bottom: .5rem; border-bottom: 1px solid var(--border);
}
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 1rem; }
#status { display: inline-flex; align-items: center; gap: .5rem; font-size: .9rem; }
.dot { width: .6rem; height: .6rem; border-radius: 50%; background: #888; flex: none; }
.dot.ok { background: #3fb950; }
.dot.err { background: #f85149; }
#stats { margin: 1rem 0; font-size: .85rem; opacity: .85; }
#status { display: inline-flex; align-items: center; gap: .5rem; font-size: .85rem; color: var(--ink-dim); }
.dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--neutral); flex: none; }
.dot.ok { background: var(--pass); }
.dot.err { background: var(--block); }
.badge {
display: inline-flex; align-items: center; font-family: var(--mono);
font-size: .72rem; font-weight: 600; letter-spacing: .02em;
padding: .2rem .5rem; border-radius: 4px; line-height: 1.4;
}
.badge-pass { color: var(--pass); background: var(--pass-bg); }
.badge-auth { color: var(--auth); background: var(--auth-bg); }
.badge-block { color: var(--block); background: var(--block-bg); }
.badge-neutral { color: var(--ink-dim); background: var(--neutral-bg); }
.badge-risk-low { color: var(--pass); background: var(--pass-bg); }
.badge-risk-medium { color: var(--auth); background: var(--auth-bg); }
.badge-risk-high, .badge-risk-critical { color: var(--block); background: var(--block-bg); }
#stats {
margin: 0 0 1.5rem; font-size: .82rem; color: var(--ink-dim);
display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center;
}
#stats .count { color: var(--ink); font-family: var(--mono); }
.empty-state {
padding: 1rem; border: 1px dashed var(--border); border-radius: 6px;
color: var(--ink-dim); font-size: .82rem; text-align: center;
}
#feed, #pending-list { list-style: none; margin: .5rem 0 0; padding: 0; }
#feed {
list-style: none; margin: .5rem 0 0; padding: 0; max-height: 60vh;
overflow-y: auto; border: 1px solid #333; border-radius: 4px;
max-height: 60vh; overflow-y: auto;
border: 1px solid var(--border); border-radius: 6px; background: var(--surface);
}
@media (prefers-color-scheme: light) { #feed { border-color: #ccc; } }
#feed:not(:empty) ~ #feed-empty { display: none; }
#feed li {
padding: .35rem .6rem; border-bottom: 1px solid #222; font-size: .8rem;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
display: flex; align-items: baseline; gap: .5rem;
padding: .5rem .7rem; border-bottom: 1px solid var(--border);
font-size: .8rem; font-family: var(--mono);
}
@media (prefers-color-scheme: light) { #feed li { border-color: #eee; } }
#feed li:last-child { border-bottom: none; }
h2 { font-size: .9rem; font-weight: 600; margin: 1.5rem 0 .5rem; }
#pending-list { list-style: none; margin: 0; padding: 0; }
#feed li .detail { color: var(--ink-dim); overflow-wrap: anywhere; }
#pending-list li {
padding: .6rem; margin-bottom: .5rem; border: 1px solid #333; border-radius: 4px;
padding: .7rem .8rem; margin-bottom: .6rem;
border: 1px solid var(--border); border-radius: 6px; background: var(--surface);
font-size: .8rem;
}
@media (prefers-color-scheme: light) { #pending-list li { border-color: #ccc; } }
#pending-list .row-explanation { margin: .3rem 0; }
#pending-list:not(:empty) ~ #pending-empty { display: none; }
#pending-list .row-header { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; flex-wrap: wrap; }
#pending-list .row-header .detail { color: var(--ink-dim); }
#pending-list .row-explanation { margin: .4rem 0; color: var(--ink-dim); }
#pending-list input[type="text"] {
font-family: inherit; font-size: .85rem; padding: .25rem .4rem; margin-right: .4rem;
width: 8rem;
font-family: var(--mono); font-size: .85rem; padding: .3rem .5rem; margin-right: .4rem;
width: 8rem; background: var(--bg); color: var(--ink); border: 1px solid var(--border); border-radius: 4px;
}
#pending-list button {
font: inherit; font-size: .8rem; padding: .3rem .7rem; margin-right: .4rem;
border: 1px solid #444; border-radius: 4px; background: transparent; color: inherit;
font: inherit; font-size: .78rem; font-weight: 600; padding: .35rem .8rem; margin-right: .4rem;
border: 1px solid var(--border); border-radius: 4px; background: transparent; color: inherit;
cursor: pointer;
}
#pending-list button.approve { border-color: #3fb950; }
#pending-list button.deny { border-color: #f85149; }
#pending-empty { opacity: .6; font-size: .8rem; }
#pending-list button.approve { border-color: var(--pass); color: var(--pass); }
#pending-list button.deny { border-color: var(--block); color: var(--block); }
</style>
</head>
<body>
<h1>Doberman Dashboard (preview)</h1>
<div id="status"><span class="dot" id="dot"></span><span id="label">connecting...</span></div>
<div class="topbar">
<h1>Doberman Dashboard</h1>
<div id="status"><span class="dot" id="dot"></span><span id="label">connecting...</span></div>
<span class="badge badge-neutral" id="mode-badge">mode: -</span>
<span class="badge badge-neutral" id="enforcement-badge">enforcement: -</span>
</div>
<div id="stats">stats loading...</div>
<h2>Pending approvals</h2>
<ul id="pending-list"></ul>
<div id="pending-empty">no pending approvals</div>
<div id="pending-empty" class="empty-state">No pending approvals right now.</div>
<h2>Recent decisions</h2>
<ul id="feed"></ul>
<div id="feed-empty" class="empty-state">Waiting for the first decision...</div>
<script>
(function () {
// Verdict/risk/enforcement -> badge class lookups. Explicit,
// exact-substring-matchable object literals (not an if/else chain) so
// the served shell can be asserted against directly by a test.
var VERDICT_BADGE_CLASS = {
PASS: "badge badge-pass",
AUTH: "badge badge-auth",
BLOCK: "badge badge-block"
};
var RISK_BADGE_CLASS = {
low: "badge badge-risk-low",
medium: "badge badge-risk-medium",
high: "badge badge-risk-high",
critical: "badge badge-risk-critical"
};
var ENFORCEMENT_BADGE_CLASS = {
enforce: "badge badge-pass",
monitor: "badge badge-auth",
off: "badge badge-block"
};

var params = new URLSearchParams(window.location.search);
var token = params.get("token") || "";
// Strip the token from the URL/history immediately so it never lingers
Expand All @@ -154,6 +232,8 @@
var dot = document.getElementById("dot");
var label = document.getElementById("label");
var statsEl = document.getElementById("stats");
var modeBadge = document.getElementById("mode-badge");
var enforcementBadge = document.getElementById("enforcement-badge");
var feedEl = document.getElementById("feed");
var MAX_FEED_ROWS = 200;

Expand All @@ -171,24 +251,51 @@
label.textContent = "not connected";
});

function renderStats(s) {
// Every piece is built via textContent, never innerHTML — mirrors
// the feed/pending-card discipline below.
statsEl.textContent = "";

var total = document.createElement("span");
total.textContent = "decisions: ";
var totalCount = document.createElement("span");
totalCount.className = "count";
totalCount.textContent = String(s.total_decisions);
total.appendChild(totalCount);
statsEl.appendChild(total);

["PASS", "AUTH", "BLOCK"].forEach(function (verdict) {
var n = (s.verdict_counts && s.verdict_counts[verdict]) || 0;
var b = document.createElement("span");
b.className = VERDICT_BADGE_CLASS[verdict];
b.textContent = verdict + ": " + n;
statsEl.appendChild(b);
});

var taint = document.createElement("span");
taint.textContent = "secret/taint events: ";
var taintCount = document.createElement("span");
taintCount.className = "count";
taintCount.textContent = String(s.secret_taint_events);
taint.appendChild(taintCount);
statsEl.appendChild(taint);

modeBadge.textContent = "mode: " + s.mode;
enforcementBadge.textContent = "enforcement: " + s.enforcement;
enforcementBadge.className = ENFORCEMENT_BADGE_CLASS[s.enforcement] || "badge badge-neutral";
}

fetch("/api/stats", { headers: { "Authorization": "Bearer " + token } })
.then(function (res) {
if (!res.ok) { throw new Error("status " + res.status); }
return res.json();
})
.then(function (s) {
statsEl.textContent =
"decisions: " + s.total_decisions +
" | verdicts: " + JSON.stringify(s.verdict_counts) +
" | secret/taint events: " + s.secret_taint_events +
" | mode: " + s.mode + " | enforcement: " + s.enforcement;
})
.then(renderStats)
.catch(function () {
statsEl.textContent = "stats unavailable";
});

var pendingList = document.getElementById("pending-list");
var pendingEmpty = document.getElementById("pending-empty");
var PENDING_POLL_MS = 2000;

function resolveApproval(id, decision, totpCode, card) {
Expand All @@ -213,19 +320,31 @@
}

function renderPending(rows) {
// The empty state is CSS-only (`#pending-list:not(:empty) ~
// #pending-empty`) - clearing to no children is enough to reveal it.
pendingList.textContent = "";
pendingEmpty.style.display = rows.length ? "none" : "block";
rows.forEach(function (row) {
var li = document.createElement("li");

var header = document.createElement("div");
header.className = "row-header";

var riskBadge = document.createElement("span");
riskBadge.className = RISK_BADGE_CLASS[row.risk] || "badge badge-neutral";
riskBadge.textContent = "RISK: " + (row.risk || "-").toUpperCase();
header.appendChild(riskBadge);

var summary = document.createElement("span");
summary.className = "detail";
// textContent only — every field is row-derived and must render
// literally, never as markup (mirrors the feed's discipline).
header.textContent = "[" + row.risk + "] " + row.action_type +
summary.textContent = row.action_type +
" " + (row.target_path_class || "-") + " (tier: " + row.tier + ")";
header.appendChild(summary);
li.appendChild(header);

var reasons = document.createElement("div");
reasons.className = "detail";
reasons.textContent = (row.reason_codes || []).join(", ") || "-";
li.appendChild(reasons);

Expand Down Expand Up @@ -290,12 +409,24 @@
return;
}
var li = document.createElement("li");

var badge = document.createElement("span");
badge.className = VERDICT_BADGE_CLASS[row.verdict] || "badge badge-neutral";
badge.textContent = row.verdict;
li.appendChild(badge);

var detail = document.createElement("span");
detail.className = "detail";
// textContent only - a row-derived string must render literally,
// never as markup (mirrors the TUI's markup=False discipline).
li.textContent = "[" + row.verdict + "] " + row.action_type +
detail.textContent = row.action_type +
" " + (row.target_path_class || "-") +
" " + (row.reason_codes && row.reason_codes.length ? row.reason_codes.join(",") : "-") +
" @ " + row.ts;
li.appendChild(detail);

// The empty state is CSS-only (`#feed:not(:empty) ~ #feed-empty`) -
// appending the first row is enough to reveal the real list.
feedEl.appendChild(li);
while (feedEl.children.length > MAX_FEED_ROWS) {
feedEl.removeChild(feedEl.firstChild);
Expand Down
Loading
Loading