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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ Shipped history for Doberman. Planned work lives on the [roadmap](README.md#road
"no decisions yet" empty-state (it stayed visible even with rows); the wrapper is removed so the
empty-state hides correctly again, and a test now guards the sibling structure the reveal needs.
The README demo gif is refreshed to the new dark brand look.
- **New:** the dashboard can now change the strictness mode itself (`GET`/`POST /api/mode`,
a `change` control next to the mode badge) instead of requiring the terminal. It routes
through the exact same gate as `doberman mode`/`doberman setup` — a new shared
`doberman.policy.drift.apply_mode_change` — so raising strictness stays frictionless and
lowering it is denied without the same possession factor (2FA if enrolled, else the Doberman
password), recorded in the same append-only ledger. The dash server never verifies the code
itself, mirroring `/api/resolve`'s existing discipline.
- **MCP tool-schema pinning** (#246): every proxied `tools/list` now records a keyed-HMAC
trust-on-first-use pin for each tool's name, description, and input schema. A later mismatch
raises live calls to AUTH in Light/Balanced or BLOCK in Strict/Paranoid until a human runs
Expand Down
10 changes: 10 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,16 @@ header bar showing the current mode + effective enforcement at a glance, and a d
state before any decisions arrive - no build step, no external assets, works fully offline like
the rest of the shell.

**Changing the mode from the dashboard.** The `change` button next to the mode badge opens a
small form (`GET`/`POST /api/mode`) to switch Light/Balanced/Strict/Paranoid without touching a
terminal. This goes through the exact same gate as `doberman mode`: raising strictness applies
immediately, and lowering it prompts for the same possession factor (a 2FA code if enrolled,
otherwise the Doberman password set via `doberman password set`) - with neither enrolled, a
lowering fails closed. Every attempt, approved or denied, is written to the same append-only
policy-change ledger (`doberman policy-history`). Exactly like `/api/resolve`, the dash server
never verifies the code itself - it only carries it through to the existing gate in
`doberman.policy.drift`.

#### Try the demo

Want to see real verdicts light up the dashboard without wiring up an agent? `doberman demo`
Expand Down
38 changes: 8 additions & 30 deletions src/doberman/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
load_preferences,
save_default_role_enabled,
save_message_tone,
save_mode,
save_policy,
save_preferences,
)
Expand All @@ -46,9 +45,9 @@
_verify_possession_factor,
apply_change,
apply_enforcement_change,
apply_mode_change,
apply_preferences_change,
apply_standing_elevation,
log_change,
read_policy_changes,
)
from doberman.policy.friction import build_friction_report, generate_proposals
Expand Down Expand Up @@ -359,35 +358,14 @@ def review(
def _apply_mode_change(
name: str, path: str, reason: str, *, establish_ok: bool = False
) -> str | None:
"""Resolve ``name``, gate any weakening behind a possession factor, then persist it.

The mode dial is now gated at parity with the enforcement dial:
lowering strictness (a downgrade on the paranoid>strict>balanced>light
scale) is a ``weaken`` and must clear a possession factor — a 2FA code if
enrolled, otherwise the Doberman password set via ``doberman password set`` —
before it is persisted. Raising stays frictionless: ``apply_change`` auto-approves
a strengthen with no prompt. A no-op (unchanged mode) skips the gate/ledger entirely.
Every attempt (incl. denials) is recorded to the append-only ledger. Returns
``None`` when the gate denies the change — fail closed, nothing persisted.

``establish_ok`` (first-run onboarding via ``doberman setup``) writes the
INITIAL posture freely when no policy is persisted yet — choosing a starting
mode is not weakening an existing one, and 2FA is enrolled later in the
wizard. The change is still recorded to the ledger. Once a policy exists,
even a setup re-run falls through to the gate, so neither ``setup`` nor
``mode`` can bypass the possession factor on a lowering.
"""Sync CLI wrapper around :func:`doberman.policy.drift.apply_mode_change`.

The gate itself (possession-factor requirement on a lowering, frictionless
raising, ``establish_ok`` first-run bypass) lives there so the CLI and the
dashboard's ``/api/mode`` share one implementation instead of two that could
drift apart.
"""
old = load_mode(path)
new = resolve_mode(name).value # raises ValueError for an unknown mode
if old == new:
return save_mode(name, path)
if establish_ok and load_policy(path) is None:
asyncio.run(log_change({"mode": old}, {"mode": new}, reason, repo_root=path))
return save_mode(name, path)
outcome = asyncio.run(apply_change({"mode": old}, {"mode": new}, reason, repo_root=path))
if not outcome.approved:
return None
return save_mode(name, path)
return asyncio.run(apply_mode_change(name, path, reason, establish_ok=establish_ok))


@app.command(rich_help_panel="Policy")
Expand Down
203 changes: 203 additions & 0 deletions src/doberman/dash/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@
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.

D6 lets the strictness mode itself be changed from the dashboard: ``GET
/api/mode`` reports the current mode + the four valid names; ``POST
/api/mode`` (body ``{"mode": <name>, "code"?: <str>}``) sets it. This goes
through the SAME chokepoint as ``doberman mode``/``doberman setup`` -
:func:`doberman.policy.drift.apply_mode_change` - so raising strictness stays
frictionless and lowering it is gated behind the same possession factor (TOTP
if enrolled, else the Doberman password), recorded in the same append-only
ledger. Exactly like ``/api/resolve``, the dash server NEVER verifies the code
itself - ``code`` rides through opaquely to the existing gate, which performs
the real verification.
"""

from __future__ import annotations
Expand All @@ -62,7 +73,10 @@
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from starlette.routing import Route

from doberman.config import load_mode
from doberman.dash.stats import build_stats, reason_codes
from doberman.policy.drift import apply_mode_change
from doberman.policy.modes import SecurityMode
from doberman.storage import approvals
from doberman.storage.log import read_decisions, read_decisions_since

Expand Down Expand Up @@ -248,6 +262,31 @@
#feed li:last-child { border-bottom: none; }
#feed li:hover { background: var(--ink-2); }
#feed li .detail { color: var(--fg-3); overflow-wrap: anywhere; }
#mode-edit-btn {
font: inherit; font-size: .7rem; font-weight: 600; padding: .2rem .5rem;
border: 1px solid var(--rule); border-radius: 4px; background: transparent;
color: var(--fg-3); cursor: pointer;
}
#mode-edit-btn:hover { background: var(--neutral-bg); color: var(--fg); }
#mode-form {
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
margin: -.2rem 0 1.2rem; font-size: .82rem;
}
#mode-form select, #mode-form input {
font: inherit; font-size: .82rem; padding: .35rem .55rem;
background: var(--ink-2); color: var(--fg); border: 1px solid var(--rule); border-radius: 4px;
}
#mode-form input { width: 16rem; letter-spacing: .04em; }
#mode-form button {
font: inherit; font-size: .8rem; font-weight: 600; padding: .35rem .85rem;
border: 1px solid var(--rule); border-radius: 4px; background: transparent;
color: inherit; cursor: pointer;
}
#mode-save-btn { border-color: var(--pass); color: var(--pass); }
#mode-save-btn:hover { background: var(--pass-bg); }
#mode-save-btn:disabled { opacity: .55; cursor: default; }
#mode-cancel-btn:hover { background: var(--neutral-bg); }
#mode-error { color: var(--block); font-family: var(--mono); font-size: .78rem; }
</style>
</head>
<body>
Expand All @@ -263,10 +302,19 @@
<div class="topbar-right">
<span class="chip" id="status"><span class="dot" id="dot"></span><span id="label">connecting...</span></span>
<span class="badge badge-neutral" id="mode-badge">mode: -</span>
<button type="button" id="mode-edit-btn">change</button>
<span class="badge badge-neutral" id="enforcement-badge">enforcement: -</span>
<span class="status-pill ok" id="guard-status"><span class="pip" id="guard-pip" aria-hidden="true">●</span><span id="guard-label">ON GUARD</span></span>
</div>
</div>
<div id="mode-form" hidden>
<select id="mode-select" aria-label="Security mode"></select>
<input id="mode-code" type="password" autocomplete="off"
placeholder="2FA code or password (only needed to lower strictness)">
<button type="button" id="mode-save-btn">Save</button>
<button type="button" id="mode-cancel-btn">Cancel</button>
<span id="mode-error"></span>
</div>
<div id="stats">stats loading...</div>
<h2>Pending approvals</h2>
<ul id="pending-list" aria-live="polite"></ul>
Expand Down Expand Up @@ -325,6 +373,14 @@
var statsEl = document.getElementById("stats");
var modeBadge = document.getElementById("mode-badge");
var enforcementBadge = document.getElementById("enforcement-badge");
var modeEditBtn = document.getElementById("mode-edit-btn");
var modeForm = document.getElementById("mode-form");
var modeSelect = document.getElementById("mode-select");
var modeCodeInput = document.getElementById("mode-code");
var modeSaveBtn = document.getElementById("mode-save-btn");
var modeCancelBtn = document.getElementById("mode-cancel-btn");
var modeErrorEl = document.getElementById("mode-error");
var modeEditing = false;
var feedEl = document.getElementById("feed");
var MAX_FEED_ROWS = 200;

Expand Down Expand Up @@ -400,6 +456,12 @@
modeBadge.textContent = "mode: " + s.mode;
enforcementBadge.textContent = "enforcement: " + s.enforcement;
enforcementBadge.className = ENFORCEMENT_BADGE_CLASS[s.enforcement] || "badge badge-neutral";
// Keep the (closed) mode selector's value in sync with reality - but
// never while the user has the form open with an in-progress choice,
// or a poll landing mid-edit would silently discard what they picked.
if (!modeEditing && modeSelect.options.length) {
modeSelect.value = s.mode;
}
}

// Stats refresh on an interval, not just at page load - otherwise the
Expand All @@ -419,6 +481,87 @@
refreshStats();
setInterval(refreshStats, STATS_REFRESH_MS);

// Mode control: fetch the valid mode names once to populate the
// selector, then let the user pick a new one. Raising strictness is
// frictionless server-side; lowering it needs a possession-factor code
// (2FA or password) in the same request - the server decides which is
// required and verifies it, this page just forwards whatever the user
// typed and shows the resulting error if any.
fetch("/api/mode", { headers: { "Authorization": "Bearer " + token } })
.then(function (res) {
if (!res.ok) { throw new Error("status " + res.status); }
return res.json();
})
.then(function (m) {
modeSelect.textContent = "";
(m.modes || []).forEach(function (name) {
var opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
modeSelect.appendChild(opt);
});
modeSelect.value = m.mode;
})
.catch(function () {
// No modes loaded -> leave the selector empty and the edit button
// inert rather than let the user submit a change we can't populate.
modeEditBtn.disabled = true;
});

function openModeForm() {
modeEditing = true;
modeErrorEl.textContent = "";
modeCodeInput.value = "";
modeForm.hidden = false;
}

function closeModeForm() {
modeEditing = false;
modeForm.hidden = true;
modeCodeInput.value = "";
modeErrorEl.textContent = "";
}

modeEditBtn.addEventListener("click", function () {
if (modeForm.hidden) { openModeForm(); } else { closeModeForm(); }
});
modeCancelBtn.addEventListener("click", closeModeForm);

modeSaveBtn.addEventListener("click", function () {
var chosen = modeSelect.value;
if (!chosen) { return; }
var body = { mode: chosen };
if (modeCodeInput.value) { body.code = modeCodeInput.value; }
modeErrorEl.textContent = "";
modeSaveBtn.disabled = true;
fetch("/api/mode", {
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}).then(function (res) {
return res.json().then(function (data) {
return { ok: res.ok, data: data };
});
}).then(function (result) {
modeSaveBtn.disabled = false;
if (result.ok) {
modeBadge.textContent = "mode: " + result.data.mode;
closeModeForm();
refreshStats();
} else {
// textContent only - never render a server error string as markup.
modeErrorEl.textContent = (result.data && result.data.error) || "mode change failed";
modeCodeInput.value = "";
}
}).catch(function () {
modeSaveBtn.disabled = false;
modeErrorEl.textContent = "network error - try again";
});
});

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

Expand Down Expand Up @@ -811,6 +954,65 @@ async def resolve(request: Request) -> Response:
return Route("/api/resolve/{approval_id}", resolve, methods=["POST"])


class _ModeChangePrompter:
"""Non-interactive :class:`~doberman.auth.challenge.Prompter` for ``POST /api/mode``.

The POST request itself is the human's confirmation (they explicitly chose
a new mode in the UI), so ``confirm`` always succeeds; ``read_code`` returns
the possession-factor code the request body carried, or raises if none was
supplied - the ``Prompter`` protocol requires a raise on no-input so the gate
treats a missing code as a denial (fail closed), never as an empty-but-valid
answer. Mirrors ``/api/resolve``: this module never verifies the code, only
carries it opaquely to :func:`doberman.policy.drift.apply_mode_change`.
"""

def __init__(self, code: str | None) -> None:
self._code = code

def confirm(self, message: str) -> bool:
return True

def read_code(self, message: str) -> str:
if not self._code:
raise ValueError("no possession-factor code supplied")
return self._code


def _make_mode_route(token: str, repo_root: str) -> Route:
async def mode_route(request: Request) -> Response:
if not _token_matches(request, token):
return _unauthorized()

if request.method == "GET":
return JSONResponse(
{"mode": load_mode(repo_root), "modes": [m.value for m in SecurityMode]}
)

try:
body = await request.json()
except (json.JSONDecodeError, ValueError):
body = {}
name = body.get("mode")
if not isinstance(name, str) or not name:
return JSONResponse({"error": "mode is required"}, status_code=400)
code = body.get("code")

try:
saved = await apply_mode_change(
name,
repo_root,
"doberman dashboard",
prompter=_ModeChangePrompter(code if isinstance(code, str) else None),
)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
if saved is None:
return JSONResponse({"error": "mode change denied"}, status_code=403)
return JSONResponse({"mode": saved})

return Route("/api/mode", mode_route, methods=["GET", "POST"])


def create_app(
token: str,
repo_root: str = ".",
Expand All @@ -837,5 +1039,6 @@ def create_app(
),
_make_pending_route(token, repo_root),
_make_resolve_route(token, repo_root),
_make_mode_route(token, repo_root),
]
return Starlette(routes=routes)
Loading
Loading