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
47 changes: 47 additions & 0 deletions scripts/test_action_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Action-class normalize + ui_fields known-goods."""

from __future__ import annotations

import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))

from ask_question_mcp.action_class import ( # noqa: E402
normalize_action_class,
resolve_action_class,
ui_fields,
)


def main() -> int:
assert normalize_action_class("SECRETS") == "secrets"
assert normalize_action_class("whatsapp") == "comms"
assert normalize_action_class("fs") == "file"
assert normalize_action_class("nope") is None

assert resolve_action_class(dangerous=True) == "destructive"
assert resolve_action_class(action_class="comms", dangerous=True) == "comms"

f = ui_fields(action_class="secrets")
assert f["dangerous"] is True
assert f["eyebrow"] == "Secrets"
assert f["css_band"] == "is-secrets"
assert "Secrets" in f["banner_prefix"]

quiet = ui_fields()
assert quiet["dangerous"] is False
assert quiet["eyebrow"] == "Decide"

file_b = ui_fields(action_class="file")
assert file_b["dangerous"] is False # FILE does not arm by itself
assert file_b["css_band"] == "is-file"

print("PASS test_action_class")
return 0


if __name__ == "__main__":
raise SystemExit(main())
128 changes: 128 additions & 0 deletions src/ask_question_mcp/action_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Action-class bands for MCQ chrome + agent Risk tagging.

Taxonomy (Alex 2026-08-09): FILE · SECRETS · COMMS · DESTRUCTIVE · POLICY
(+ quiet/default when unset).
"""

from __future__ import annotations

from typing import Any

# Canonical ids (lowercase). Aliases normalize into these.
ACTION_CLASSES = ("file", "secrets", "comms", "destructive", "policy")

_ALIASES: dict[str, str] = {
"file": "file",
"fs": "file",
"filesystem": "file",
"read": "file",
"write": "file",
"secrets": "secrets",
"secret": "secrets",
"creds": "secrets",
"credential": "secrets",
"credentials": "secrets",
"comms": "comms",
"communicate": "comms",
"communication": "comms",
"send": "comms",
"whatsapp": "comms",
"email": "comms",
"destructive": "destructive",
"danger": "destructive",
"delete": "destructive",
"destroy": "destructive",
"policy": "policy",
"governance": "policy",
}

# Eyebrow + banner labels (short, uppercase-friendly in UI).
_LABELS: dict[str, str] = {
"file": "File",
"secrets": "Secrets",
"comms": "Comms",
"destructive": "Destructive",
"policy": "Policy",
}

_MARKS: dict[str, str] = {
"file": "📁",
"secrets": "🔐",
"comms": "📡",
"destructive": "⛔",
"policy": "⚖",
}

# Bands that arm OK + show confirm chrome (same family as dangerous=true).
_ARMS: frozenset[str] = frozenset(
{"secrets", "comms", "destructive", "policy"}
)


def normalize_action_class(raw: Any) -> str | None:
"""Return canonical action_class or None if unset/unknown."""
if raw is None:
return None
key = str(raw).strip().lower().replace("-", "_").replace(" ", "_")
if not key:
return None
return _ALIASES.get(key)


def action_class_label(action_class: str | None) -> str | None:
if not action_class:
return None
return _LABELS.get(action_class)


def action_class_mark(action_class: str | None) -> str:
if not action_class:
return "⛔"
return _MARKS.get(action_class, "⛔")


def action_class_arms(action_class: str | None) -> bool:
"""True when this band should arm OK / confirm chrome."""
return bool(action_class and action_class in _ARMS)


def resolve_action_class(
*,
action_class: Any = None,
dangerous: bool = False,
) -> str | None:
"""Normalize explicit class; if only ``dangerous``, map to destructive."""
cls = normalize_action_class(action_class)
if cls:
return cls
if dangerous:
return "destructive"
return None


def ui_fields(
*,
action_class: Any = None,
dangerous: bool = False,
) -> dict[str, Any]:
"""Fields to merge into Nebula/Gtk UI payload."""
cls = resolve_action_class(action_class=action_class, dangerous=dangerous)
armed = bool(dangerous) or action_class_arms(cls)
label = action_class_label(cls)
mark = action_class_mark(cls)
if cls and label:
eyebrow = label
banner = f"{mark} {label} — "
elif armed:
eyebrow = "Confirm"
banner = f"{mark} Confirm — "
else:
eyebrow = "Decide"
banner = ""
return {
"action_class": cls,
"dangerous": armed,
"eyebrow": eyebrow,
"banner_prefix": banner,
"css_band": f"is-{cls}" if cls else ("is-danger" if armed else ""),
}
108 changes: 105 additions & 3 deletions src/ask_question_mcp/assets/dialog/dialog.css
Original file line number Diff line number Diff line change
Expand Up @@ -264,12 +264,35 @@ body {
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.08);
}

.eyebrow.is-danger {
.eyebrow.is-danger,
.eyebrow.is-destructive {
color: var(--danger-text);
background: var(--danger-bg);
border-color: var(--danger-border);
}

/* Action-class bands (Alex 2026-08-09) — FILE · SECRETS · COMMS · DESTRUCTIVE · POLICY */
.eyebrow.is-file {
color: #bfdbfe;
background: rgb(59 130 246 / 0.16);
border-color: rgb(59 130 246 / 0.35);
}
.eyebrow.is-secrets {
color: #fde68a;
background: rgb(217 119 6 / 0.18);
border-color: rgb(217 119 6 / 0.4);
}
.eyebrow.is-comms {
color: #99f6e4;
background: rgb(13 148 136 / 0.18);
border-color: rgb(13 148 136 / 0.4);
}
.eyebrow.is-policy {
color: #fed7aa;
background: rgb(194 65 12 / 0.18);
border-color: rgb(194 65 12 / 0.4);
}

.agent {
overflow: hidden;
text-overflow: ellipsis;
Expand Down Expand Up @@ -390,6 +413,51 @@ body {
color: var(--danger-text);
}

.banner.is-file .banner-inner {
background: rgb(59 130 246 / 0.14);
}
.banner.is-file .banner-accent {
background: #3b82f6;
}
.banner.is-file .banner-copy {
color: #bfdbfe;
}

.banner.is-secrets .banner-inner {
background: rgb(217 119 6 / 0.16);
}
.banner.is-secrets .banner-accent {
background: #d97706;
}
.banner.is-secrets .banner-copy {
color: #fde68a;
}

.banner.is-comms .banner-inner {
background: rgb(13 148 136 / 0.16);
}
.banner.is-comms .banner-accent {
background: #0d9488;
}
.banner.is-comms .banner-copy {
color: #99f6e4;
}

.banner.is-policy .banner-inner {
background: rgb(194 65 12 / 0.16);
}
.banner.is-policy .banner-accent {
background: #c2410c;
}
.banner.is-policy .banner-copy {
color: #fed7aa;
}

.banner.is-destructive .banner-inner,
.banner.is-danger .banner-inner {
background: var(--danger-bg);
}

.options {
flex: 1 1 auto;
min-height: 48px;
Expand Down Expand Up @@ -965,15 +1033,49 @@ body {
background: #fff;
}

.btn-primary.is-danger {
.btn-primary.is-danger,
.btn-primary.is-destructive {
background: var(--ember);
color: #1a0610;
}

.btn-primary.is-danger:hover:not(:disabled) {
.btn-primary.is-danger:hover:not(:disabled),
.btn-primary.is-destructive:hover:not(:disabled) {
background: #ff7a94;
}

.btn-primary.is-file {
background: #3b82f6;
color: #0b1220;
}
.btn-primary.is-file:hover:not(:disabled) {
background: #60a5fa;
}

.btn-primary.is-secrets {
background: #d97706;
color: #1a0f00;
}
.btn-primary.is-secrets:hover:not(:disabled) {
background: #f59e0b;
}

.btn-primary.is-comms {
background: #0d9488;
color: #041412;
}
.btn-primary.is-comms:hover:not(:disabled) {
background: #14b8a6;
}

.btn-primary.is-policy {
background: #c2410c;
color: #1a0a00;
}
.btn-primary.is-policy:hover:not(:disabled) {
background: #ea580c;
}

.btn-icon {
width: 28px;
height: 28px;
Expand Down
26 changes: 22 additions & 4 deletions src/ask_question_mcp/assets/dialog/dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -678,19 +678,37 @@
state.focusIdx = Math.max(0, ids.indexOf(focusId));

const dangerous = !!(payload.dangerous || (payload.danger_ids || []).length);
$("#eyebrow").textContent = dangerous ? "Confirm" : "Decide";
$("#eyebrow").classList.toggle("is-danger", dangerous);
const band =
String(payload.action_class || "").toLowerCase() ||
(dangerous ? "destructive" : "");
const bandClass = band ? `is-${band}` : "";
const eyebrowEl = $("#eyebrow");
const BANDS = ["file", "secrets", "comms", "destructive", "policy", "danger"];
eyebrowEl.textContent =
payload.eyebrow || (dangerous ? "Confirm" : "Decide");
for (const b of BANDS) eyebrowEl.classList.remove(`is-${b}`);
if (bandClass) eyebrowEl.classList.add(bandClass);
else if (dangerous) eyebrowEl.classList.add("is-danger");
$("#title-agent").textContent = payload.agent_hint || payload.title || "";
$("#question").textContent = payload.question || "";

const banner = $("#banner");
banner.classList.toggle("is-on", dangerous);
for (const b of BANDS) banner.classList.remove(`is-${b}`);
if (bandClass) banner.classList.add(bandClass);
else if (dangerous) banner.classList.add("is-danger");
const prefix =
payload.banner_prefix ||
(dangerous ? "⛔ Confirm — " : "");
$("#banner-copy").textContent = dangerous
? `⛔ Confirm — ${payload.question || ""}`
? `${prefix}${payload.question || ""}`
: "";

const ok = $("#ok-btn");
ok.classList.toggle("is-danger", dangerous);
for (const b of BANDS) ok.classList.remove(`is-${b}`);
ok.classList.toggle("is-danger", dangerous && (!band || band === "destructive"));
if (bandClass && band !== "destructive") ok.classList.add(bandClass);
else if (dangerous) ok.classList.add("is-danger");

const showOther = payload.allow_other !== false;
$("#freeform").hidden = !showOther;
Expand Down
22 changes: 19 additions & 3 deletions src/ask_question_mcp/linux_webview_ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,9 +891,25 @@ def main() -> int:
str(x) for x in (payload.get("recommended_ids") or [])
]
ui_payload["danger_ids"] = [str(x) for x in (payload.get("danger_ids") or [])]
ui_payload["dangerous"] = bool(
payload.get("dangerous") or ui_payload["danger_ids"]
)
try:
from ask_question_mcp.action_class import ui_fields as _action_ui_fields

_band = _action_ui_fields(
action_class=payload.get("action_class"),
dangerous=bool(payload.get("dangerous") or ui_payload["danger_ids"]),
)
ui_payload["action_class"] = _band.get("action_class")
ui_payload["eyebrow"] = _band.get("eyebrow")
ui_payload["banner_prefix"] = _band.get("banner_prefix")
ui_payload["css_band"] = _band.get("css_band")
ui_payload["dangerous"] = bool(_band["dangerous"])
except Exception:
ui_payload["dangerous"] = bool(
payload.get("dangerous") or ui_payload["danger_ids"]
)
for key in ("action_class", "eyebrow", "banner_prefix", "css_band"):
if payload.get(key) is not None:
ui_payload[key] = payload.get(key)
ui_payload["allow_multiple"] = bool(payload.get("allow_multiple"))
ui_payload["allow_other"] = bool(payload.get("allow_other", True))
timeout_sec = int(payload.get("timeout_sec") or 0)
Expand Down
Loading