feat: add permission consent prompts, drop permitted capability mask
This commit is contained in:
1 parent
c54181103f
commit
ae5e84592d
25 files changed
+1632
-94
No files matched your search
@@ -56,7 +56,7 @@ LDFLAGS := \
|
||||
|
||||
# ---- C++ source files ----
|
||||
|
||||
CORE_SRCS := main.cpp window.cpp panel.cpp wifi.cpp battery.cpp compose.cpp input.cpp dialogs.cpp launcher.cpp desktop_builtin.cpp desktop_catalog.cpp font_data.cpp stb_truetype_impl.cpp
|
||||
CORE_SRCS := main.cpp window.cpp panel.cpp wifi.cpp battery.cpp compose.cpp input.cpp dialogs.cpp launcher.cpp desktop_builtin.cpp desktop_catalog.cpp consent.cpp font_data.cpp stb_truetype_impl.cpp
|
||||
APP_SRCS := $(sort $(shell find apps -name '*.cpp' -print))
|
||||
SRCS := $(CORE_SRCS) $(APP_SRCS)
|
||||
OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o))
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* consent.cpp
|
||||
* Consent agent: answers the kernel's first-use capability requests from
|
||||
* stored decisions, or asks the user in a prompt window (docs/consent.md)
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#include "desktop_internal.hpp"
|
||||
#include <gui/mtk.hpp>
|
||||
#include <montauk/consent.h>
|
||||
|
||||
// The prompt is an ordinary window inside the desktop, but an INTERNAL one:
|
||||
// its pixels live in the desktop's heap, not in a window-server surface, so
|
||||
// no app can map it (SYS_WINMAP) and only real input routed by the desktop
|
||||
// reaches it. What remains is clickjacking -- an app raising a window over
|
||||
// the prompt, or stealing focus, just as the user clicks. So Allow only
|
||||
// arms after the prompt has been focused and on top for ARM_DELAY_MS without
|
||||
// interruption; losing either restarts the delay. Don't Allow and closing
|
||||
// are always safe and never wait.
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int PROMPT_W = 460;
|
||||
constexpr int PAD = 20;
|
||||
constexpr int ICON = 32;
|
||||
constexpr int BTN_W = 120;
|
||||
constexpr int BTN_GAP = 12;
|
||||
constexpr int MAX_LINES = 6;
|
||||
constexpr int LINE_CHARS = 160;
|
||||
constexpr uint64_t ARM_DELAY_MS = 700;
|
||||
|
||||
struct ConsentPrompt {
|
||||
DesktopState* ds;
|
||||
montauk::abi::ConsentRequest req;
|
||||
char headline[MAX_LINES][LINE_CHARS];
|
||||
int headline_lines;
|
||||
char path[2][LINE_CHARS];
|
||||
int path_lines;
|
||||
uint64_t opened_at;
|
||||
uint64_t armed_since; // 0 while not both focused and on top
|
||||
bool armed;
|
||||
bool answered;
|
||||
bool hover_allow;
|
||||
bool hover_deny;
|
||||
};
|
||||
|
||||
bool g_registered = false;
|
||||
ConsentPrompt* g_open = nullptr; // one prompt at a time; the rest wait in the kernel
|
||||
Color g_icon_fill = {};
|
||||
|
||||
// Prompt icons, loaded on first use: one per capability plus the fallback.
|
||||
struct CachedIcon {
|
||||
const char* path;
|
||||
SvgIcon icon;
|
||||
};
|
||||
CachedIcon g_icons[8] = {};
|
||||
|
||||
const SvgIcon* icon_at(const char* path) {
|
||||
for (auto& c : g_icons) {
|
||||
if (c.path && montauk::streq(c.path, path)) return c.icon.pixels ? &c.icon : nullptr;
|
||||
if (c.path == nullptr) {
|
||||
c.path = path;
|
||||
c.icon = svg_load(path, ICON, ICON, g_icon_fill);
|
||||
return c.icon.pixels ? &c.icon : nullptr;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const SvgIcon* icon_for(uint64_t capability) {
|
||||
const SvgIcon* icon = icon_at(montauk::consent::icon_for(capability));
|
||||
return icon ? icon : icon_at(montauk::consent::DEFAULT_ICON);
|
||||
}
|
||||
|
||||
// ==== Text ====
|
||||
|
||||
// Greedy word wrap into fixed line buffers. A word wider than the line is
|
||||
// hard-broken so hostile input cannot overflow the layout.
|
||||
int wrap_text(const char* text, int max_w, char (*lines)[LINE_CHARS], int max_lines) {
|
||||
int count = 0;
|
||||
const char* p = text;
|
||||
while (*p && count < max_lines) {
|
||||
while (*p == ' ') p++;
|
||||
if (!*p) break;
|
||||
|
||||
char* line = lines[count];
|
||||
int len = 0;
|
||||
int last_break = -1;
|
||||
const char* resume = p;
|
||||
while (*p && len < LINE_CHARS - 1) {
|
||||
line[len] = *p;
|
||||
line[len + 1] = '\0';
|
||||
if (text_width(line) > max_w && len > 0) {
|
||||
line[len] = '\0';
|
||||
break;
|
||||
}
|
||||
if (*p == ' ') {
|
||||
last_break = len;
|
||||
resume = p + 1;
|
||||
}
|
||||
len++;
|
||||
p++;
|
||||
}
|
||||
line[len] = '\0';
|
||||
if (*p && *p != ' ' && last_break > 0) {
|
||||
// Back up to the last space so a word is not split.
|
||||
line[last_break] = '\0';
|
||||
p = resume;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
if (*p && count == max_lines) {
|
||||
// Out of lines: mark the cut rather than hide it.
|
||||
char* last = lines[max_lines - 1];
|
||||
int len = montauk::slen(last);
|
||||
if (len > LINE_CHARS - 4) len = LINE_CHARS - 4;
|
||||
while (len > 0 && text_width(last) + text_width("...") > max_w) last[--len] = '\0';
|
||||
last[len] = '.'; last[len + 1] = '.'; last[len + 2] = '.'; last[len + 3] = '\0';
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// The file name is chosen by whoever put the binary there. Show it as
|
||||
// printable ASCII so control bytes cannot fake the prompt's layout.
|
||||
void make_printable(char* s) {
|
||||
for (; *s; s++) {
|
||||
if (*s < 0x20 || *s >= 0x7F) *s = '?';
|
||||
}
|
||||
}
|
||||
|
||||
// "0:/apps/foo/foo.elf" -> "foo" when no installed app claims the path.
|
||||
void app_name_for(const char* path, char* out, int cap) {
|
||||
for (int i = 0; i < menu_row_count; i++) {
|
||||
const MenuRow& row = menu_rows[i];
|
||||
if (row.external && montauk::streq(row.binary_path, path)) {
|
||||
montauk::strncpy(out, row.label, cap);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const char* base = path;
|
||||
for (const char* p = path; *p; p++) {
|
||||
if (*p == '/' || *p == ':') base = p + 1;
|
||||
}
|
||||
montauk::strncpy(out, base, cap);
|
||||
int len = montauk::slen(out);
|
||||
if (len > 4 && montauk::streq(out + len - 4, ".elf")) out[len - 4] = '\0';
|
||||
}
|
||||
|
||||
// ==== Layout ====
|
||||
|
||||
gui::mtk::Theme prompt_theme(const DesktopState* ds) {
|
||||
return gui::mtk::make_theme(ds->settings.accent_color);
|
||||
}
|
||||
|
||||
int text_w() {
|
||||
return PROMPT_W - 2 * BORDER_WIDTH - PAD * 2 - ICON - 14;
|
||||
}
|
||||
|
||||
int line_h() {
|
||||
return system_font_height() + 5;
|
||||
}
|
||||
|
||||
Rect allow_rect(int cw, int ch, const gui::mtk::Theme& theme) {
|
||||
return {cw - PAD - BTN_W * 2 - BTN_GAP, ch - PAD - theme.control_h, BTN_W, theme.control_h};
|
||||
}
|
||||
|
||||
Rect deny_rect(int cw, int ch, const gui::mtk::Theme& theme) {
|
||||
return {cw - PAD - BTN_W, ch - PAD - theme.control_h, BTN_W, theme.control_h};
|
||||
}
|
||||
|
||||
int content_height(const ConsentPrompt* cp, const gui::mtk::Theme& theme) {
|
||||
int h = PAD;
|
||||
h += cp->headline_lines * line_h() + 10;
|
||||
h += cp->path_lines * line_h() + 12;
|
||||
h += line_h() * 2 + 8; // trust advice
|
||||
if (!cp->req.imageProtected) h += line_h() * 2 + 8;
|
||||
h += theme.control_h + PAD;
|
||||
return h;
|
||||
}
|
||||
|
||||
// ==== Answering ====
|
||||
|
||||
ConsentPrompt* prompt_of(Window* win) {
|
||||
return win ? (ConsentPrompt*)win->app_data : nullptr;
|
||||
}
|
||||
|
||||
// `remember` is the user's intent; it only takes effect for an installed
|
||||
// program, since any other binary can be swapped at the same path.
|
||||
void answer(ConsentPrompt* cp, bool allow, bool remember) {
|
||||
if (cp->answered) return;
|
||||
cp->answered = true;
|
||||
if (remember && cp->req.imageProtected &&
|
||||
montauk::consent::record(cp->req.imagePath, cp->req.capability, allow) < 0) {
|
||||
montauk::print("[desktop] consent: could not save the decision to 0:/config/consent.toml\n");
|
||||
}
|
||||
montauk::consent_reply(cp->req.id, allow ? montauk::abi::CONSENT_ALLOW
|
||||
: montauk::abi::CONSENT_DENY);
|
||||
}
|
||||
|
||||
void close_prompt(ConsentPrompt* cp) {
|
||||
DesktopState* ds = cp->ds;
|
||||
for (int i = 0; i < ds->window_count; i++) {
|
||||
if (ds->windows[i].app_data == cp) {
|
||||
desktop_close_window(ds, i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool prompt_is_front(const DesktopState* ds, const Window* win) {
|
||||
return !ds->screen_locked && ds->window_count > 0 &&
|
||||
win == &ds->windows[ds->window_count - 1] && win->focused &&
|
||||
win->state != WIN_MINIMIZED && win->state != WIN_CLOSED;
|
||||
}
|
||||
|
||||
// ==== Window callbacks ====
|
||||
|
||||
void prompt_on_draw(Window* win, Framebuffer& fb) {
|
||||
(void)fb;
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp) return;
|
||||
gui::mtk::Theme theme = prompt_theme(cp->ds);
|
||||
|
||||
Canvas c(win);
|
||||
c.fill(theme.window_bg);
|
||||
|
||||
if (const SvgIcon* icon = icon_for(cp->req.capability)) c.icon(PAD, PAD, *icon);
|
||||
|
||||
int x = PAD + ICON + 14;
|
||||
int y = PAD;
|
||||
int lh = line_h();
|
||||
for (int i = 0; i < cp->headline_lines; i++, y += lh)
|
||||
c.text(x, y, cp->headline[i], theme.text);
|
||||
y += 10;
|
||||
|
||||
for (int i = 0; i < cp->path_lines; i++, y += lh)
|
||||
c.text(x, y, cp->path[i], theme.text_subtle);
|
||||
y += 12;
|
||||
|
||||
c.text(x, y, "Only allow this if you trust the app and", theme.text_muted);
|
||||
c.text(x, y + lh, "expected it to ask.", theme.text_muted);
|
||||
if (!cp->req.imageProtected) {
|
||||
y += lh * 2 + 8;
|
||||
c.text(x, y, "This app is not installed in 0:/apps, so the", theme.text_muted);
|
||||
c.text(x, y + lh, "answer applies only until it exits.", theme.text_muted);
|
||||
}
|
||||
|
||||
gui::mtk::draw_button(c, allow_rect(c.w, c.h, theme), "Allow",
|
||||
gui::mtk::BUTTON_SECONDARY,
|
||||
gui::mtk::widget_state(false, cp->hover_allow && cp->armed, cp->armed),
|
||||
theme);
|
||||
gui::mtk::draw_button(c, deny_rect(c.w, c.h, theme), "Don't Allow",
|
||||
gui::mtk::BUTTON_PRIMARY,
|
||||
gui::mtk::widget_state(false, cp->hover_deny, true),
|
||||
theme);
|
||||
}
|
||||
|
||||
void prompt_on_poll(Window* win) {
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp || cp->answered) return;
|
||||
uint64_t now = montauk::get_milliseconds();
|
||||
|
||||
// The kernel has already denied it; an answer now would be refused.
|
||||
if (now - cp->opened_at >= montauk::abi::CONSENT_TIMEOUT_MS) {
|
||||
cp->answered = true;
|
||||
close_prompt(cp);
|
||||
return;
|
||||
}
|
||||
|
||||
bool front = prompt_is_front(cp->ds, win);
|
||||
if (!front) {
|
||||
cp->armed_since = 0;
|
||||
} else if (cp->armed_since == 0) {
|
||||
cp->armed_since = now;
|
||||
}
|
||||
bool armed = front && now - cp->armed_since >= ARM_DELAY_MS;
|
||||
if (armed != cp->armed) {
|
||||
cp->armed = armed;
|
||||
win->dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void prompt_on_mouse(Window* win, MouseEvent& ev) {
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp || cp->answered) return;
|
||||
|
||||
gui::mtk::Theme theme = prompt_theme(cp->ds);
|
||||
gui::mtk::DesktopHost host(win);
|
||||
int mx = 0;
|
||||
int my = 0;
|
||||
if (!host.map_mouse(ev, &mx, &my)) return;
|
||||
cp->hover_allow = allow_rect(win->content_w, win->content_h, theme).contains(mx, my);
|
||||
cp->hover_deny = deny_rect(win->content_w, win->content_h, theme).contains(mx, my);
|
||||
|
||||
if (!ev.left_pressed()) return;
|
||||
if (cp->hover_deny) {
|
||||
answer(cp, false, true);
|
||||
close_prompt(cp);
|
||||
} else if (cp->hover_allow && cp->armed && prompt_is_front(cp->ds, win)) {
|
||||
answer(cp, true, true);
|
||||
close_prompt(cp);
|
||||
}
|
||||
}
|
||||
|
||||
void prompt_on_key(Window* win, const montauk::abi::KeyEvent& key) {
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp || cp->answered || !key.pressed) return;
|
||||
|
||||
if (key.scancode == 0x01) { // Escape: dismiss, ask again later
|
||||
answer(cp, false, false);
|
||||
close_prompt(cp);
|
||||
} else if ((key.ascii == '\n' || key.ascii == '\r') && cp->armed) {
|
||||
// Return picks the default, Don't Allow. Armed so a key held while
|
||||
// the prompt appeared does not answer it.
|
||||
answer(cp, false, true);
|
||||
close_prompt(cp);
|
||||
}
|
||||
}
|
||||
|
||||
void prompt_on_close(Window* win) {
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp) return;
|
||||
// Closed without an answer (title-bar close, session ending): deny this
|
||||
// time only.
|
||||
answer(cp, false, false);
|
||||
if (g_open == cp) g_open = nullptr;
|
||||
montauk::mfree(cp);
|
||||
win->app_data = nullptr;
|
||||
}
|
||||
|
||||
// ==== Opening ====
|
||||
|
||||
// Centre over the requesting app's window when it has one, so the prompt is
|
||||
// visibly about that app; otherwise centre on the screen.
|
||||
void place_prompt(DesktopState* ds, int pid, int w, int h, int* out_x, int* out_y) {
|
||||
int cx = ds->screen_w / 2;
|
||||
int cy = ds->screen_h / 2;
|
||||
|
||||
montauk::abi::WinInfo infos[MAX_WINDOWS];
|
||||
int count = montauk::win_enumerate(infos, MAX_WINDOWS);
|
||||
for (int e = 0; e < count; e++) {
|
||||
if (infos[e].ownerPid != pid) continue;
|
||||
for (int i = 0; i < ds->window_count; i++) {
|
||||
const Window& win = ds->windows[i];
|
||||
if (!win.external || win.ext_win_id != infos[e].id) continue;
|
||||
if (win.state == WIN_MINIMIZED || win.state == WIN_CLOSED) continue;
|
||||
cx = win.frame.x + win.frame.w / 2;
|
||||
cy = win.frame.y + win.frame.h / 2;
|
||||
e = count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int x = cx - w / 2;
|
||||
int y = cy - h / 2;
|
||||
if (x + w > ds->screen_w) x = ds->screen_w - w;
|
||||
if (y + h > ds->screen_h) y = ds->screen_h - h;
|
||||
if (x < 0) x = 0;
|
||||
if (y < PANEL_HEIGHT) y = PANEL_HEIGHT;
|
||||
*out_x = x;
|
||||
*out_y = y;
|
||||
}
|
||||
|
||||
bool open_prompt(DesktopState* ds, const montauk::abi::ConsentRequest& req) {
|
||||
ConsentPrompt* cp = (ConsentPrompt*)montauk::malloc(sizeof(ConsentPrompt));
|
||||
if (!cp) return false;
|
||||
montauk::memset(cp, 0, sizeof(*cp));
|
||||
cp->ds = ds;
|
||||
cp->req = req;
|
||||
cp->opened_at = montauk::get_milliseconds();
|
||||
|
||||
char name[48];
|
||||
app_name_for(req.imagePath, name, sizeof(name));
|
||||
make_printable(name);
|
||||
char shown_path[sizeof(req.imagePath)];
|
||||
montauk::strncpy(shown_path, req.imagePath, sizeof(shown_path));
|
||||
make_printable(shown_path);
|
||||
char headline[320];
|
||||
snprintf(headline, sizeof(headline), "\"%s\" wants to %s.", name,
|
||||
montauk::consent::action_for(req.capability));
|
||||
cp->headline_lines = wrap_text(headline, text_w(), cp->headline, MAX_LINES);
|
||||
// The path is where the user can tell a system app from a download.
|
||||
cp->path_lines = wrap_text(shown_path, text_w(), cp->path, 2);
|
||||
|
||||
gui::mtk::Theme theme = prompt_theme(ds);
|
||||
int w = PROMPT_W;
|
||||
int h = content_height(cp, theme) + TITLEBAR_HEIGHT + BORDER_WIDTH;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
place_prompt(ds, req.pid, w, h, &x, &y);
|
||||
|
||||
int idx = desktop_create_window(ds, "Permission Request", x, y, w, h);
|
||||
if (idx < 0) {
|
||||
montauk::mfree(cp);
|
||||
return false;
|
||||
}
|
||||
|
||||
Window* win = &ds->windows[idx];
|
||||
win->app_data = cp;
|
||||
win->on_draw = prompt_on_draw;
|
||||
win->on_mouse = prompt_on_mouse;
|
||||
win->on_key = prompt_on_key;
|
||||
win->on_poll = prompt_on_poll;
|
||||
win->on_close = prompt_on_close;
|
||||
g_open = cp;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ==== Agent ====
|
||||
|
||||
void desktop_consent_init(DesktopState* ds) {
|
||||
// Every session registers; a standard session simply never receives a
|
||||
// request, because it has none of the eligible capabilities to delegate.
|
||||
g_registered = montauk::consent_register() == 0;
|
||||
if (!g_registered) {
|
||||
montauk::print("[desktop] consent: could not register as the consent agent\n");
|
||||
return;
|
||||
}
|
||||
g_icon_fill = gui::mtk::make_theme(ds->settings.accent_color).text;
|
||||
}
|
||||
|
||||
bool desktop_consent_poll(DesktopState* ds) {
|
||||
if (!g_registered || g_open != nullptr) return false;
|
||||
|
||||
// Drain whatever the stored decisions settle; stop at the first request
|
||||
// that needs the user.
|
||||
for (;;) {
|
||||
montauk::abi::ConsentRequest req;
|
||||
if (montauk::consent_next(&req) != 1) return false;
|
||||
|
||||
int stored = req.imageProtected
|
||||
? montauk::consent::lookup(req.imagePath, req.capability) : -1;
|
||||
if (stored >= 0) {
|
||||
montauk::consent_reply(req.id, stored ? montauk::abi::CONSENT_ALLOW
|
||||
: montauk::abi::CONSENT_DENY);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!open_prompt(ds, req)) {
|
||||
// Could not show it, so the user cannot have agreed.
|
||||
montauk::consent_reply(req.id, montauk::abi::CONSENT_DENY);
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,10 @@ inline gui::Rect desktop_content_rect(const gui::Window* win) {
|
||||
gui::ResizeEdge hit_test_resize_edge(const gui::Rect& f, int mx, int my);
|
||||
gui::CursorStyle cursor_for_edge(gui::ResizeEdge edge);
|
||||
|
||||
// consent.cpp
|
||||
void desktop_consent_init(gui::DesktopState* ds);
|
||||
bool desktop_consent_poll(gui::DesktopState* ds);
|
||||
|
||||
// panel.cpp
|
||||
void desktop_draw_app_menu(gui::DesktopState* ds);
|
||||
void desktop_draw_net_popup(gui::DesktopState* ds);
|
||||
|
||||
@@ -393,6 +393,7 @@ void gui::desktop_init(DesktopState* ds) {
|
||||
desktop_refresh_netifs(ds);
|
||||
desktop_wifi_init(ds);
|
||||
desktop_battery_init(ds);
|
||||
desktop_consent_init(ds);
|
||||
|
||||
ds->vol_popup_open = false;
|
||||
ds->vol_icon_rect = {0, 0, 0, 0};
|
||||
@@ -796,6 +797,9 @@ void gui::desktop_run(DesktopState* ds) {
|
||||
// Poll external windows (discover new, remove dead, update dirty)
|
||||
sceneChanged |= desktop_poll_external_windows(ds);
|
||||
|
||||
// Answer or prompt for pending first-use capability requests.
|
||||
if (!ds->screen_locked) sceneChanged |= desktop_consent_poll(ds);
|
||||
|
||||
if (!ds->screen_locked) {
|
||||
// Poll windows that have a poll callback
|
||||
for (int i = 0; i < ds->window_count; i++) {
|
||||
|
||||
Reference in new issue
Block a user