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
@@ -4,6 +4,17 @@
|
||||
# Capability names: process_admin, power_request, power_control, suspend,
|
||||
# storage_admin, raw_storage, network_admin, set_time, user_admin,
|
||||
# display_admin, device_admin, log_read, system_image, compositor and "all".
|
||||
#
|
||||
# `effective` is what the program itself may use; `delegable` is what it may
|
||||
# pass on to programs it launches. The two are independent, and both are
|
||||
# clamped by the kernel to what the launcher can delegate.
|
||||
#
|
||||
# A grant only applies to a program under 0:/os or 0:/apps. Anything else can
|
||||
# be replaced by any process, so the kernel starts it with no capabilities.
|
||||
#
|
||||
# A program with no grant here can still ask for storage_admin, network_admin,
|
||||
# set_time, display_admin or log_read the first time it needs one: the desktop
|
||||
# prompts, and the answer is stored in 0:/config/consent.toml (docs/consent.md).
|
||||
|
||||
# ==== System services (started by init) ====
|
||||
|
||||
@@ -17,7 +28,6 @@ effective = ["set_time"]
|
||||
|
||||
[grant.login]
|
||||
path = "0:/os/login.elf"
|
||||
permitted = ["all", "compositor"]
|
||||
effective = [
|
||||
"power_control", "storage_admin", "device_admin",
|
||||
"user_admin", "process_admin", "log_read",
|
||||
@@ -86,6 +96,11 @@ effective = ["set_time"]
|
||||
path = "0:/apps/bluetooth/bluetooth.elf"
|
||||
effective = ["device_admin"]
|
||||
|
||||
# Writes 0:/config/consent.toml when a stored consent decision is forgotten.
|
||||
[grant.capabilities]
|
||||
path = "0:/apps/capabilities/capabilities.elf"
|
||||
effective = ["user_admin"]
|
||||
|
||||
[grant.syslog]
|
||||
path = "0:/apps/syslog/syslog.elf"
|
||||
effective = ["log_read"]
|
||||
|
||||
@@ -265,6 +265,13 @@ namespace montauk::abi {
|
||||
/* Storage.hpp */
|
||||
static constexpr uint64_t SYS_VOLINFO = 190; // (VolInfo*, maxCount) -> count, -1 bad pointer
|
||||
|
||||
/* Consent.hpp -- first-use consent prompts (docs/consent.md). Only the
|
||||
session's consent agent (the desktop) may register, drain requests
|
||||
and answer them. */
|
||||
static constexpr uint64_t SYS_CONSENT_REGISTER = 191; // () -> 0, -13 lacks CAP_COMPOSITOR, -1 another agent is live
|
||||
static constexpr uint64_t SYS_CONSENT_NEXT = 192; // (ConsentRequest*) -> 1 delivered, 0 none queued
|
||||
static constexpr uint64_t SYS_CONSENT_REPLY = 193; // (requestId, CONSENT_ALLOW/DENY) -> 0, -1 stale id
|
||||
|
||||
/* Kernel-owned process capabilities. User identities may namespace
|
||||
per-user resources, but never participate in authorization decisions. */
|
||||
static constexpr uint64_t CAP_PROCESS_ADMIN = 1ULL << 0;
|
||||
@@ -309,26 +316,55 @@ namespace montauk::abi {
|
||||
"login grants it to the desktop directly");
|
||||
static constexpr int SYS_ERR_PERMISSION = -13;
|
||||
|
||||
// A process holds two independent sets: what it may use (effective) and
|
||||
// what it may pass to children (delegable). A launcher can delegate what
|
||||
// it never uses itself, so neither set bounds the other.
|
||||
struct SpawnCapabilities {
|
||||
uint64_t permitted;
|
||||
uint64_t effective;
|
||||
uint64_t delegable;
|
||||
};
|
||||
|
||||
// Both of the child's sets must come out of the parent's delegable set.
|
||||
constexpr bool ValidCapabilityDelegation(const SpawnCapabilities& child,
|
||||
uint64_t parentDelegable) {
|
||||
return (child.permitted & ~CAP_ALL) == 0 &&
|
||||
(child.effective & ~child.permitted) == 0 &&
|
||||
(child.delegable & ~child.permitted) == 0 &&
|
||||
(child.permitted & ~parentDelegable) == 0 &&
|
||||
return ((child.effective | child.delegable) & ~CAP_ALL) == 0 &&
|
||||
(child.effective & ~parentDelegable) == 0 &&
|
||||
(child.delegable & ~parentDelegable) == 0;
|
||||
}
|
||||
static_assert(ValidCapabilityDelegation(
|
||||
{CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN, 0}, CAP_NETWORK_ADMIN));
|
||||
{CAP_NETWORK_ADMIN, 0}, CAP_NETWORK_ADMIN));
|
||||
static_assert(ValidCapabilityDelegation(
|
||||
{0, CAP_NETWORK_ADMIN}, CAP_NETWORK_ADMIN));
|
||||
static_assert(!ValidCapabilityDelegation(
|
||||
{CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN}, 0));
|
||||
{CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN}, 0));
|
||||
static_assert(!ValidCapabilityDelegation(
|
||||
{CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN | CAP_SET_TIME, 0}, CAP_ALL));
|
||||
{CAP_NETWORK_ADMIN | CAP_SET_TIME, 0}, CAP_NETWORK_ADMIN));
|
||||
static_assert(!ValidCapabilityDelegation(
|
||||
{0, CAP_NETWORK_ADMIN | CAP_SET_TIME}, CAP_NETWORK_ADMIN));
|
||||
|
||||
/* Capabilities a user can grant at first use through a consent prompt.
|
||||
A consent grant never exceeds what the process's parent could have
|
||||
delegated to it at spawn (its consent ceiling), is never delegable
|
||||
onwards, and is issued only by the session's consent agent -- so it is
|
||||
deferred delegation, not amplification. */
|
||||
static constexpr uint64_t CAP_CONSENT_ELIGIBLE =
|
||||
CAP_STORAGE_ADMIN | CAP_NETWORK_ADMIN | CAP_SET_TIME |
|
||||
CAP_DISPLAY_ADMIN | CAP_LOG_READ;
|
||||
static_assert((CAP_CONSENT_ELIGIBLE & ~CAP_ADMIN_SESSION) == 0,
|
||||
"consent can only hand out authority an admin session holds");
|
||||
|
||||
static constexpr int CONSENT_DENY = 0;
|
||||
static constexpr int CONSENT_ALLOW = 1;
|
||||
// A request left unanswered this long is denied (and not remembered).
|
||||
static constexpr uint64_t CONSENT_TIMEOUT_MS = 120000;
|
||||
|
||||
struct ConsentRequest {
|
||||
uint64_t id;
|
||||
uint64_t capability; // exactly one CAP_* bit
|
||||
int32_t pid;
|
||||
uint32_t imageProtected; // 1 when writing the image needs CAP_SYSTEM_IMAGE
|
||||
char imagePath[256];
|
||||
};
|
||||
|
||||
|
||||
static constexpr int USB_ERR_INVALID = -1;
|
||||
@@ -837,7 +873,6 @@ namespace montauk::abi {
|
||||
char name[64];
|
||||
uint64_t heapUsed; // Distance from UserHeapBase to high-water mark
|
||||
uint64_t cpuTimeMs; // accumulated scheduler runtime
|
||||
uint64_t permittedCaps;
|
||||
uint64_t effectiveCaps;
|
||||
uint64_t delegableCaps;
|
||||
};
|
||||
|
||||
@@ -214,6 +214,9 @@ extern "C" {
|
||||
#define MTK_SYS_BATTERYINFO 188
|
||||
#define MTK_SYS_GETCAPS 189
|
||||
#define MTK_SYS_VOLINFO 190
|
||||
#define MTK_SYS_CONSENT_REGISTER 191
|
||||
#define MTK_SYS_CONSENT_NEXT 192
|
||||
#define MTK_SYS_CONSENT_REPLY 193
|
||||
/* @SYSCALLS-END */
|
||||
|
||||
#define MTK_SOCK_TCP 1
|
||||
|
||||
@@ -56,6 +56,32 @@ namespace caps {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// True when a grant for `path` can apply: the program is installed under
|
||||
// 0:/os or 0:/apps, which cannot be rewritten without system_image. The
|
||||
// kernel enforces this (Fs::IsSystemImage) by starting any other program
|
||||
// without capabilities; this copy of the rule is only for display.
|
||||
inline bool is_installed_path(const char* path) {
|
||||
if (path == nullptr) return false;
|
||||
const char* p = path;
|
||||
uint64_t drive = 0;
|
||||
if (*p < '0' || *p > '9') return false;
|
||||
while (*p >= '0' && *p <= '9') drive = drive * 10 + (uint64_t)(*p++ - '0');
|
||||
if (*p != ':' || drive != 0) return false;
|
||||
p++;
|
||||
static const char* const roots[] = {"/os", "/apps"};
|
||||
for (const char* root : roots) {
|
||||
int i = 0;
|
||||
while (root[i]) {
|
||||
char c = p[i];
|
||||
if (c >= 'A' && c <= 'Z') c = (char)(c + ('a' - 'A'));
|
||||
if (c != root[i]) break;
|
||||
i++;
|
||||
}
|
||||
if (root[i] == '\0' && (p[i] == '\0' || p[i] == '/')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read an array-of-strings key into a capability mask.
|
||||
inline uint64_t mask_from_key(const montauk::toml::Doc& doc, const char* key) {
|
||||
montauk::toml::Value* arr = doc.get_array(key);
|
||||
@@ -97,7 +123,7 @@ namespace caps {
|
||||
// path has no entry, which is the common case and means "no authority".
|
||||
inline bool lookup(const char* binary_path,
|
||||
montauk::abi::SpawnCapabilities& out) {
|
||||
out = {0, 0, 0};
|
||||
out = {0, 0};
|
||||
if (binary_path == nullptr || binary_path[0] == '\0') return false;
|
||||
|
||||
montauk::toml::Doc doc = montauk::config::load(GRANT_CONFIG);
|
||||
@@ -117,15 +143,7 @@ namespace caps {
|
||||
uint64_t effective = mask_from_key(doc, sibling);
|
||||
build_sibling_key(entry->key, "delegable", sibling, sizeof(sibling));
|
||||
uint64_t delegable = mask_from_key(doc, sibling);
|
||||
build_sibling_key(entry->key, "permitted", sibling, sizeof(sibling));
|
||||
uint64_t permitted = mask_from_key(doc, sibling);
|
||||
|
||||
// A grant that does not name `permitted` owns exactly what it can
|
||||
// use or pass on. Declaring it separately is only needed by a
|
||||
// supervisor that holds authority in reserve (login).
|
||||
if (permitted == 0) permitted = effective | delegable;
|
||||
|
||||
out.permitted = permitted;
|
||||
out.effective = effective;
|
||||
out.delegable = delegable;
|
||||
found = true;
|
||||
@@ -137,7 +155,7 @@ namespace caps {
|
||||
|
||||
// The calling process's own capability masks.
|
||||
inline bool self(montauk::abi::SpawnCapabilities& out) {
|
||||
out = {0, 0, 0};
|
||||
out = {0, 0};
|
||||
return montauk::getcaps(&out) == 0;
|
||||
}
|
||||
|
||||
@@ -150,15 +168,14 @@ namespace caps {
|
||||
// actually delegate.
|
||||
inline montauk::abi::SpawnCapabilities for_binary(const char* binary_path,
|
||||
uint64_t caller_delegable) {
|
||||
montauk::abi::SpawnCapabilities caps{0, 0, 0};
|
||||
montauk::abi::SpawnCapabilities caps{0, 0};
|
||||
|
||||
if (caller_delegable == 0) return caps;
|
||||
|
||||
if (!lookup(binary_path, caps)) return caps;
|
||||
|
||||
caps.permitted &= caller_delegable;
|
||||
caps.effective &= caps.permitted;
|
||||
caps.delegable &= caps.permitted;
|
||||
caps.effective &= caller_delegable;
|
||||
caps.delegable &= caller_delegable;
|
||||
return caps;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* consent.h
|
||||
* First-use consent: prompt wording and the stored-decision table
|
||||
* (0:/config/consent.toml). See docs/consent.md.
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <montauk/syscall.h>
|
||||
#include <montauk/string.h>
|
||||
#include <montauk/toml.h>
|
||||
#include <montauk/config.h>
|
||||
#include <montauk/capabilities.h>
|
||||
|
||||
namespace montauk {
|
||||
namespace consent {
|
||||
|
||||
inline constexpr const char* STORE_CONFIG = "consent";
|
||||
inline constexpr const char* STORE_PREFIX = "decision.";
|
||||
|
||||
struct CapText {
|
||||
uint64_t bit;
|
||||
const char* action; // completes "<App> wants to ..."
|
||||
const char* icon; // shown in the prompt
|
||||
};
|
||||
|
||||
inline constexpr CapText TEXTS[] = {
|
||||
{montauk::abi::CAP_STORAGE_ADMIN, "format, partition and mount disks",
|
||||
"0:/os/icons/drive-harddisk.svg"},
|
||||
{montauk::abi::CAP_NETWORK_ADMIN, "change network settings and join Wi-Fi networks",
|
||||
"0:/os/icons/network-wired.svg"},
|
||||
{montauk::abi::CAP_SET_TIME, "change the system clock and time zone",
|
||||
"0:/os/icons/preferences-system-time.svg"},
|
||||
{montauk::abi::CAP_DISPLAY_ADMIN, "change the screen resolution and brightness",
|
||||
"0:/os/icons/preferences-desktop-display.svg"},
|
||||
{montauk::abi::CAP_LOG_READ, "read the system log, which can reveal what other apps and devices are doing",
|
||||
"0:/os/icons/utilities-log-viewer.svg"},
|
||||
};
|
||||
|
||||
// Fallback for a capability without its own icon.
|
||||
inline constexpr const char* DEFAULT_ICON = "0:/os/icons/lock.svg";
|
||||
|
||||
inline const char* action_for(uint64_t bit) {
|
||||
for (const auto& t : TEXTS) {
|
||||
if (t.bit == bit) return t.action;
|
||||
}
|
||||
return "use a protected system capability";
|
||||
}
|
||||
|
||||
inline const char* icon_for(uint64_t bit) {
|
||||
for (const auto& t : TEXTS) {
|
||||
if (t.bit == bit) return t.icon;
|
||||
}
|
||||
return DEFAULT_ICON;
|
||||
}
|
||||
|
||||
inline const char* name_for(uint64_t bit) {
|
||||
for (const auto& n : montauk::caps::NAMES) {
|
||||
if (n.bit == bit) return n.name;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// One stored decision, as read from the table. Only programs installed
|
||||
// under 0:/os or 0:/apps are ever stored: those paths cannot be rewritten
|
||||
// without CAP_SYSTEM_IMAGE, so the path alone identifies the program.
|
||||
// The kernel reports this as ConsentRequest::imageProtected.
|
||||
struct Decision {
|
||||
char id[16];
|
||||
char path[256];
|
||||
uint64_t capability;
|
||||
bool allow;
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
inline void key(char* out, int cap, const char* id, const char* field) {
|
||||
int n = 0;
|
||||
for (const char* p = STORE_PREFIX; *p && n < cap - 1; p++) out[n++] = *p;
|
||||
for (const char* p = id; *p && n < cap - 1; p++) out[n++] = *p;
|
||||
if (n < cap - 1) out[n++] = '.';
|
||||
for (const char* p = field; *p && n < cap - 1; p++) out[n++] = *p;
|
||||
out[n] = '\0';
|
||||
}
|
||||
|
||||
// Ids of "decision.<id>.path" entries, in file order.
|
||||
inline int collect_ids(const montauk::toml::Doc& doc, char (*ids)[16], int max) {
|
||||
int count = 0;
|
||||
int prefixLen = montauk::slen(STORE_PREFIX);
|
||||
for (int i = 0; i < doc.entries.count && count < max; i++) {
|
||||
montauk::toml::Value* v = doc.entries.items[i];
|
||||
if (v == nullptr || v->key == nullptr) continue;
|
||||
if (!montauk::starts_with(v->key, STORE_PREFIX)) continue;
|
||||
const char* rest = v->key + prefixLen;
|
||||
int dot = 0;
|
||||
while (rest[dot] && rest[dot] != '.') dot++;
|
||||
if (rest[dot] != '.' || !montauk::streq(rest + dot + 1, "path")) continue;
|
||||
if (dot <= 0 || dot >= 16) continue;
|
||||
montauk::memcpy(ids[count], rest, dot);
|
||||
ids[count][dot] = '\0';
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
inline bool read(const montauk::toml::Doc& doc, const char* id, Decision& out) {
|
||||
char k[64];
|
||||
montauk::memset(&out, 0, sizeof(out));
|
||||
montauk::strncpy(out.id, id, sizeof(out.id));
|
||||
key(k, sizeof(k), id, "path");
|
||||
montauk::strncpy(out.path, doc.get_string(k, ""), sizeof(out.path));
|
||||
key(k, sizeof(k), id, "capability");
|
||||
out.capability = montauk::caps::bit_for_name(doc.get_string(k, ""));
|
||||
key(k, sizeof(k), id, "decision");
|
||||
out.allow = montauk::streq(doc.get_string(k, ""), "allow");
|
||||
// "all" or an unknown name is never a valid stored decision.
|
||||
return out.path[0] &&
|
||||
(out.capability & montauk::abi::CAP_CONSENT_ELIGIBLE) != 0 &&
|
||||
(out.capability & (out.capability - 1)) == 0;
|
||||
}
|
||||
|
||||
inline void remove_id(montauk::toml::Doc* doc, const char* id) {
|
||||
static const char* fields[] = {"path", "capability", "decision"};
|
||||
char k[64];
|
||||
for (const char* f : fields) {
|
||||
key(k, sizeof(k), id, f);
|
||||
montauk::config::unset(doc, k);
|
||||
}
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
// Read up to `max` stored decisions. Returns the count.
|
||||
inline int load_all(Decision* out, int max) {
|
||||
montauk::toml::Doc doc = montauk::config::load(STORE_CONFIG);
|
||||
char ids[128][16];
|
||||
int idCount = detail::collect_ids(doc, ids, 128);
|
||||
int count = 0;
|
||||
for (int i = 0; i < idCount && count < max; i++) {
|
||||
if (detail::read(doc, ids[i], out[count])) count++;
|
||||
}
|
||||
doc.destroy();
|
||||
return count;
|
||||
}
|
||||
|
||||
// Stored answer for (path, capability): 1 allow, 0 deny, -1 none.
|
||||
inline int lookup(const char* path, uint64_t capability) {
|
||||
montauk::toml::Doc doc = montauk::config::load(STORE_CONFIG);
|
||||
char ids[128][16];
|
||||
int idCount = detail::collect_ids(doc, ids, 128);
|
||||
int result = -1;
|
||||
for (int i = 0; i < idCount && result < 0; i++) {
|
||||
Decision d;
|
||||
if (!detail::read(doc, ids[i], d)) continue;
|
||||
if (d.capability == capability && montauk::streq(d.path, path)) {
|
||||
result = d.allow ? 1 : 0;
|
||||
}
|
||||
}
|
||||
doc.destroy();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Store an answer, replacing any earlier one for the same path and
|
||||
// capability. Returns 0 on success, negative when the table cannot be
|
||||
// written.
|
||||
inline int record(const char* path, uint64_t capability, bool allow) {
|
||||
montauk::toml::Doc doc = montauk::config::load(STORE_CONFIG);
|
||||
char ids[128][16];
|
||||
int idCount = detail::collect_ids(doc, ids, 128);
|
||||
int nextId = 1;
|
||||
for (int i = 0; i < idCount; i++) {
|
||||
Decision d;
|
||||
bool valid = detail::read(doc, ids[i], d);
|
||||
if (valid && d.capability == capability && montauk::streq(d.path, path))
|
||||
detail::remove_id(&doc, ids[i]);
|
||||
int n = 0;
|
||||
for (const char* p = ids[i]; *p >= '0' && *p <= '9'; p++) n = n * 10 + (*p - '0');
|
||||
if (n >= nextId) nextId = n + 1;
|
||||
}
|
||||
|
||||
char id[16];
|
||||
int len = 0;
|
||||
char rev[16];
|
||||
for (int n = nextId; n > 0 && len < 15; n /= 10) rev[len++] = (char)('0' + n % 10);
|
||||
for (int i = 0; i < len; i++) id[i] = rev[len - 1 - i];
|
||||
id[len] = '\0';
|
||||
|
||||
char k[64];
|
||||
detail::key(k, sizeof(k), id, "path");
|
||||
montauk::config::set_string(&doc, k, path);
|
||||
detail::key(k, sizeof(k), id, "capability");
|
||||
montauk::config::set_string(&doc, k, name_for(capability));
|
||||
detail::key(k, sizeof(k), id, "decision");
|
||||
montauk::config::set_string(&doc, k, allow ? "allow" : "deny");
|
||||
|
||||
int rc = montauk::config::save(STORE_CONFIG, &doc);
|
||||
doc.destroy();
|
||||
return rc;
|
||||
}
|
||||
|
||||
// Drop one stored decision by id; the program is asked again next time.
|
||||
inline int forget(const char* id) {
|
||||
montauk::toml::Doc doc = montauk::config::load(STORE_CONFIG);
|
||||
detail::remove_id(&doc, id);
|
||||
int rc = montauk::config::save(STORE_CONFIG, &doc);
|
||||
doc.destroy();
|
||||
return rc;
|
||||
}
|
||||
|
||||
} // namespace consent
|
||||
} // namespace montauk
|
||||
@@ -131,6 +131,17 @@ namespace montauk {
|
||||
inline int getcaps(montauk::abi::SpawnCapabilities* out) {
|
||||
return (int)syscall1(montauk::abi::SYS_GETCAPS, (uint64_t)out);
|
||||
}
|
||||
|
||||
// Consent agent (the desktop only; see docs/consent.md).
|
||||
inline int consent_register() {
|
||||
return (int)syscall0(montauk::abi::SYS_CONSENT_REGISTER);
|
||||
}
|
||||
inline int consent_next(montauk::abi::ConsentRequest* out) {
|
||||
return (int)syscall1(montauk::abi::SYS_CONSENT_NEXT, (uint64_t)out);
|
||||
}
|
||||
inline int consent_reply(uint64_t id, int decision) {
|
||||
return (int)syscall2(montauk::abi::SYS_CONSENT_REPLY, id, (uint64_t)decision);
|
||||
}
|
||||
inline int chdir(const char* path) {
|
||||
return (int)syscall1(montauk::abi::SYS_CHDIR, (uint64_t)path);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <montauk/string.h>
|
||||
#include <montauk/config.h>
|
||||
#include <montauk/capabilities.h>
|
||||
#include <montauk/consent.h>
|
||||
#include <gui/mtk.hpp>
|
||||
#include <gui/mtk/settings.hpp>
|
||||
#include <gui/standalone.hpp>
|
||||
@@ -30,6 +31,7 @@ static constexpr int GRID_ROW_H = 22;
|
||||
static constexpr int GRID_COL_W = 86;
|
||||
static constexpr int MAX_GRANTS = 64;
|
||||
static constexpr int MAX_ISSUES = 4;
|
||||
static constexpr int MAX_DECISIONS = 64;
|
||||
|
||||
static constexpr int CAP_COUNT =
|
||||
(int)(sizeof(montauk::caps::NAMES) / sizeof(montauk::caps::NAMES[0]));
|
||||
@@ -37,12 +39,14 @@ static constexpr int CAP_COUNT =
|
||||
enum Tab {
|
||||
TAB_PROGRAMS = 0,
|
||||
TAB_CAPS = 1,
|
||||
TAB_COUNT = 2,
|
||||
TAB_CONSENT = 2,
|
||||
TAB_COUNT = 3,
|
||||
};
|
||||
|
||||
static const char* const kTabLabels[TAB_COUNT] = {
|
||||
"Programs",
|
||||
"Capabilities",
|
||||
"Consent",
|
||||
};
|
||||
|
||||
// What each capability gates, in the order of montauk::caps::NAMES.
|
||||
@@ -68,16 +72,15 @@ static const char* const kCapDescriptions[CAP_COUNT] = {
|
||||
enum Field {
|
||||
FIELD_EFFECTIVE = 0,
|
||||
FIELD_DELEGABLE = 1,
|
||||
FIELD_PERMITTED = 2,
|
||||
FIELD_COUNT = 3,
|
||||
FIELD_COUNT = 2,
|
||||
};
|
||||
|
||||
static const char* const kFieldKeys[FIELD_COUNT] = {
|
||||
"effective", "delegable", "permitted",
|
||||
"effective", "delegable",
|
||||
};
|
||||
|
||||
static const char* const kFieldLabels[FIELD_COUNT] = {
|
||||
"Effective", "Delegable", "Permitted",
|
||||
"Effective", "Delegable",
|
||||
};
|
||||
|
||||
struct Grant {
|
||||
@@ -104,6 +107,11 @@ static Color g_accent = colors::ACCENT;
|
||||
static bool g_is_admin = false;
|
||||
static int g_selected_grant = 0;
|
||||
static int g_selected_cap = 0;
|
||||
static int g_selected_decision = 0;
|
||||
|
||||
// Answers given to consent prompts (0:/config/consent.toml).
|
||||
static montauk::consent::Decision g_decisions[MAX_DECISIONS];
|
||||
static int g_decision_count = 0;
|
||||
static int g_list_scroll = 0;
|
||||
static bool g_list_sb_dragging = false;
|
||||
static int g_list_sb_drag_offset = 0;
|
||||
@@ -214,14 +222,6 @@ static void load_grants() {
|
||||
|
||||
for (int i = 0; i < g_grant_count; i++) {
|
||||
Grant& g = g_grants[i];
|
||||
uint64_t* m = g.mask;
|
||||
|
||||
// Same rule as caps::lookup(): an undeclared permitted set is exactly
|
||||
// what the grant can use or pass on.
|
||||
if (!g.declared[FIELD_PERMITTED] || m[FIELD_PERMITTED] == 0)
|
||||
m[FIELD_PERMITTED] = m[FIELD_EFFECTIVE] | m[FIELD_DELEGABLE];
|
||||
else if ((m[FIELD_EFFECTIVE] | m[FIELD_DELEGABLE]) & ~m[FIELD_PERMITTED])
|
||||
add_issue(g, "Effective/delegable exceed permitted; the excess is dropped at launch");
|
||||
|
||||
if (g.path[0] == '\0') {
|
||||
add_issue(g, "No path; this grant never applies");
|
||||
@@ -231,6 +231,8 @@ static void load_grants() {
|
||||
g.binary_present = bh >= 0;
|
||||
if (bh >= 0) montauk::close(bh);
|
||||
if (!g.binary_present) add_issue(g, "Binary not found on this system");
|
||||
if (!montauk::caps::is_installed_path(g.path))
|
||||
add_issue(g, "Never applies: not under 0:/os or 0:/apps, so it runs with no capabilities");
|
||||
|
||||
for (int j = 0; j < i; j++) {
|
||||
if (montauk::streq(g_grants[j].path, g.path)) {
|
||||
@@ -244,6 +246,25 @@ static void load_grants() {
|
||||
if (g_selected_grant >= g_grant_count) g_selected_grant = 0;
|
||||
}
|
||||
|
||||
static void load_decisions() {
|
||||
g_decision_count = montauk::consent::load_all(g_decisions, MAX_DECISIONS);
|
||||
if (g_selected_decision >= g_decision_count)
|
||||
g_selected_decision = g_decision_count > 0 ? g_decision_count - 1 : 0;
|
||||
}
|
||||
|
||||
static const char* base_name(const char* path) {
|
||||
const char* base = path;
|
||||
for (const char* p = path; *p; p++)
|
||||
if (*p == '/' || *p == ':') base = p + 1;
|
||||
return base;
|
||||
}
|
||||
|
||||
static int cap_index(uint64_t bit) {
|
||||
for (int i = 0; i < CAP_COUNT; i++)
|
||||
if (montauk::caps::NAMES[i].bit == bit) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ==== Layout ====
|
||||
|
||||
static mtk::Theme app_theme() {
|
||||
@@ -271,7 +292,15 @@ static Rect list_rect() {
|
||||
}
|
||||
|
||||
static int list_count() {
|
||||
return g_tab == TAB_PROGRAMS ? g_grant_count : CAP_COUNT;
|
||||
if (g_tab == TAB_PROGRAMS) return g_grant_count;
|
||||
if (g_tab == TAB_CONSENT) return g_decision_count;
|
||||
return CAP_COUNT;
|
||||
}
|
||||
|
||||
static int* selected_for_tab() {
|
||||
if (g_tab == TAB_PROGRAMS) return &g_selected_grant;
|
||||
if (g_tab == TAB_CONSENT) return &g_selected_decision;
|
||||
return &g_selected_cap;
|
||||
}
|
||||
|
||||
static int list_visible_rows() {
|
||||
@@ -322,6 +351,15 @@ static Rect reload_button() {
|
||||
return {foot.x + foot.w - PAD - BUTTON_W, foot.y + 7, BUTTON_W, 30};
|
||||
}
|
||||
|
||||
static Rect forget_button() {
|
||||
Rect reload = reload_button();
|
||||
return {reload.x - 8 - BUTTON_W, reload.y, BUTTON_W, reload.h};
|
||||
}
|
||||
|
||||
static bool forget_visible() {
|
||||
return g_is_admin && g_tab == TAB_CONSENT && g_decision_count > 0;
|
||||
}
|
||||
|
||||
static bool mouse_in(const Rect& rect) {
|
||||
return rect.contains(g_mouse_x, g_mouse_y);
|
||||
}
|
||||
@@ -382,17 +420,24 @@ static void draw_list(Canvas& c, const mtk::Theme& theme) {
|
||||
int index = g_list_scroll + row;
|
||||
if (index >= count) break;
|
||||
Rect r = list_row_rect(row);
|
||||
bool selected = index == (g_tab == TAB_PROGRAMS ? g_selected_grant : g_selected_cap);
|
||||
bool selected = index == *selected_for_tab();
|
||||
mtk::draw_list_row(c, r, selected, false, theme);
|
||||
if (!selected && mouse_in(r))
|
||||
c.fill_rounded_rect(r.x, r.y, r.w, r.h, theme.radius_md, theme.surface_hover);
|
||||
|
||||
const char* label = g_tab == TAB_PROGRAMS
|
||||
? g_grants[index].id : montauk::caps::NAMES[index].name;
|
||||
const char* label = g_tab == TAB_PROGRAMS ? g_grants[index].id
|
||||
: g_tab == TAB_CONSENT ? base_name(g_decisions[index].path)
|
||||
: montauk::caps::NAMES[index].name;
|
||||
Color fg = selected ? theme.accent_fg : theme.text;
|
||||
int ty = r.y + (r.h - fh) / 2;
|
||||
draw_text_fit(c, r.x + 10, ty, label, r.w - 34, fg);
|
||||
|
||||
if (g_tab == TAB_CONSENT) {
|
||||
Color dot = selected ? theme.accent_fg
|
||||
: g_decisions[index].allow ? theme.accent : theme.text_muted;
|
||||
fill_circle(c, r.x + r.w - 14, r.y + r.h / 2, 4, dot);
|
||||
}
|
||||
|
||||
if (g_tab == TAB_PROGRAMS && g_grants[index].issue_count > 0) {
|
||||
Color dot = selected ? theme.accent_fg : theme.danger;
|
||||
fill_circle(c, r.x + r.w - 14, r.y + r.h / 2, 4, dot);
|
||||
@@ -459,7 +504,6 @@ static void draw_programs_pane(Canvas& c, const mtk::Theme& theme) {
|
||||
for (int f = 0; f < FIELD_COUNT; f++) {
|
||||
const char* note = nullptr;
|
||||
if (g.uses_all[f]) note = "(all)";
|
||||
else if (f == FIELD_PERMITTED && !g.declared[f]) note = "(implied)";
|
||||
if (!note) continue;
|
||||
int cx = colX + f * GRID_COL_W + GRID_COL_W / 2;
|
||||
c.text(cx - text_width(note) / 2, y, note, theme.text_muted);
|
||||
@@ -468,7 +512,7 @@ static void draw_programs_pane(Canvas& c, const mtk::Theme& theme) {
|
||||
|
||||
for (int i = 0; i < CAP_COUNT; i++) {
|
||||
uint64_t bit = montauk::caps::NAMES[i].bit;
|
||||
bool any = (g.mask[FIELD_PERMITTED] & bit) != 0;
|
||||
bool any = ((g.mask[FIELD_EFFECTIVE] | g.mask[FIELD_DELEGABLE]) & bit) != 0;
|
||||
if (i % 2 == 1)
|
||||
c.fill_rect(x, y, w, GRID_ROW_H, theme.surface_alt);
|
||||
c.text(x + 6, y + (GRID_ROW_H - fh) / 2, montauk::caps::NAMES[i].name,
|
||||
@@ -541,6 +585,43 @@ static void draw_caps_pane(Canvas& c, const mtk::Theme& theme) {
|
||||
"MAY PASS IT ON (DELEGABLE)", FIELD_DELEGABLE, cap.bit, theme);
|
||||
}
|
||||
|
||||
static void draw_consent_pane(Canvas& c, const mtk::Theme& theme) {
|
||||
int x = pane_x();
|
||||
int w = pane_w();
|
||||
int fh = system_font_height();
|
||||
int y = TAB_H + PAD;
|
||||
|
||||
if (g_decision_count == 0) {
|
||||
c.text(x, y, "No consent decisions are stored.", theme.text_subtle);
|
||||
c.text(x, y + fh + 8, "Programs without a grant ask the first time they need", theme.text_muted);
|
||||
c.text(x, y + (fh + 8) * 2, "storage, network, time, display or log access.", theme.text_muted);
|
||||
return;
|
||||
}
|
||||
|
||||
const montauk::consent::Decision& d = g_decisions[g_selected_decision];
|
||||
c.text(x, y, base_name(d.path), theme.text);
|
||||
const char* state = d.allow ? "Allowed" : "Denied";
|
||||
c.text(x + w - text_width(state), y, state, d.allow ? theme.accent : theme.danger);
|
||||
y += fh + 6;
|
||||
draw_text_fit(c, x, y, d.path, w, theme.text_subtle);
|
||||
|
||||
y += fh + 14;
|
||||
mtk::draw_separator(c, x, y, w, theme);
|
||||
y += 14;
|
||||
|
||||
int ci = cap_index(d.capability);
|
||||
c.text(x, y, "CAPABILITY", theme.text_muted);
|
||||
y += fh + 6;
|
||||
c.text(x + 10, y, ci >= 0 ? montauk::caps::NAMES[ci].name : "?", theme.text);
|
||||
y += fh + 4;
|
||||
if (ci >= 0) draw_text_fit(c, x + 10, y, kCapDescriptions[ci], w - 10, theme.text_subtle);
|
||||
y += fh + 16;
|
||||
|
||||
c.text(x, y, "Forget makes the program ask again the next time it needs", theme.text_muted);
|
||||
y += fh + 4;
|
||||
c.text(x, y, "this. A copy already running keeps its access until it exits.", theme.text_muted);
|
||||
}
|
||||
|
||||
static void draw_locked(Canvas& c, const mtk::Theme& theme) {
|
||||
int fh = system_font_height();
|
||||
int y = TAB_H + 60;
|
||||
@@ -564,9 +645,13 @@ static void draw_footer(Canvas& c, const mtk::Theme& theme) {
|
||||
int flagged = 0;
|
||||
for (int i = 0; i < g_grant_count; i++)
|
||||
if (g_grants[i].issue_count > 0) flagged++;
|
||||
snprintf(summary, sizeof(summary), "%d grant%s%s, %d flagged - read-only view",
|
||||
g_grant_count, g_grant_count == 1 ? "" : "s",
|
||||
g_truncated ? " (list truncated)" : "", flagged);
|
||||
if (g_tab == TAB_CONSENT)
|
||||
snprintf(summary, sizeof(summary), "%d stored decision%s",
|
||||
g_decision_count, g_decision_count == 1 ? "" : "s");
|
||||
else
|
||||
snprintf(summary, sizeof(summary), "%d grant%s%s, %d flagged - read-only view",
|
||||
g_grant_count, g_grant_count == 1 ? "" : "s",
|
||||
g_truncated ? " (list truncated)" : "", flagged);
|
||||
}
|
||||
c.text(PAD, foot.y + (foot.h - system_font_height()) / 2, summary, theme.text_subtle);
|
||||
|
||||
@@ -575,6 +660,11 @@ static void draw_footer(Canvas& c, const mtk::Theme& theme) {
|
||||
mtk::draw_button(c, reload, "Reload", mtk::BUTTON_SECONDARY,
|
||||
mtk::widget_state(false, mouse_in(reload), true), theme);
|
||||
}
|
||||
if (forget_visible()) {
|
||||
Rect forget = forget_button();
|
||||
mtk::draw_button(c, forget, "Forget", mtk::BUTTON_DANGER,
|
||||
mtk::widget_state(false, mouse_in(forget), true), theme);
|
||||
}
|
||||
}
|
||||
|
||||
static void render() {
|
||||
@@ -586,6 +676,7 @@ static void render() {
|
||||
mtk::draw_tab_bar(c, tab_bar_rect(), kTabLabels, TAB_COUNT, (int)g_tab, theme);
|
||||
draw_list(c, theme);
|
||||
if (g_tab == TAB_PROGRAMS) draw_programs_pane(c, theme);
|
||||
else if (g_tab == TAB_CONSENT) draw_consent_pane(c, theme);
|
||||
else draw_caps_pane(c, theme);
|
||||
} else {
|
||||
draw_locked(c, theme);
|
||||
@@ -598,8 +689,25 @@ static void render() {
|
||||
|
||||
static void reload() {
|
||||
load_grants();
|
||||
load_decisions();
|
||||
clamp_list_scroll();
|
||||
set_status("Reloaded 0:/config/capabilities.toml");
|
||||
set_status(g_tab == TAB_CONSENT ? "Reloaded 0:/config/consent.toml"
|
||||
: "Reloaded 0:/config/capabilities.toml");
|
||||
}
|
||||
|
||||
static void forget_selected() {
|
||||
if (g_decision_count == 0) return;
|
||||
const montauk::consent::Decision& d = g_decisions[g_selected_decision];
|
||||
if (montauk::consent::forget(d.id) < 0) {
|
||||
set_status("Could not write 0:/config/consent.toml");
|
||||
return;
|
||||
}
|
||||
char msg[128];
|
||||
snprintf(msg, sizeof(msg), "Forgot %s for %s", montauk::consent::name_for(d.capability),
|
||||
base_name(d.path));
|
||||
load_decisions();
|
||||
clamp_list_scroll();
|
||||
set_status(msg);
|
||||
}
|
||||
|
||||
static void select_index(int index) {
|
||||
@@ -607,8 +715,7 @@ static void select_index(int index) {
|
||||
if (count <= 0) return;
|
||||
if (index < 0) index = 0;
|
||||
if (index >= count) index = count - 1;
|
||||
if (g_tab == TAB_PROGRAMS) g_selected_grant = index;
|
||||
else g_selected_cap = index;
|
||||
*selected_for_tab() = index;
|
||||
|
||||
int visible = list_visible_rows();
|
||||
if (index < g_list_scroll) g_list_scroll = index;
|
||||
@@ -619,7 +726,7 @@ static void select_index(int index) {
|
||||
static void switch_tab(Tab tab) {
|
||||
g_tab = tab;
|
||||
g_list_scroll = 0;
|
||||
select_index(tab == TAB_PROGRAMS ? g_selected_grant : g_selected_cap);
|
||||
select_index(*selected_for_tab());
|
||||
}
|
||||
|
||||
static void handle_mouse(const montauk::abi::WinEvent& ev) {
|
||||
@@ -675,6 +782,7 @@ static void handle_mouse(const montauk::abi::WinEvent& ev) {
|
||||
}
|
||||
}
|
||||
if (reload_button().contains(mx, my)) reload();
|
||||
if (forget_visible() && forget_button().contains(mx, my)) forget_selected();
|
||||
}
|
||||
|
||||
static bool handle_key(const montauk::abi::KeyEvent& key) {
|
||||
@@ -685,10 +793,10 @@ static bool handle_key(const montauk::abi::KeyEvent& key) {
|
||||
}
|
||||
if (!g_is_admin) return false;
|
||||
if (key.ascii == '\t') {
|
||||
switch_tab(g_tab == TAB_PROGRAMS ? TAB_CAPS : TAB_PROGRAMS);
|
||||
switch_tab((Tab)((g_tab + 1) % TAB_COUNT));
|
||||
return true;
|
||||
}
|
||||
int current = g_tab == TAB_PROGRAMS ? g_selected_grant : g_selected_cap;
|
||||
int current = *selected_for_tab();
|
||||
if (key.scancode == 0x48) { select_index(current - 1); return true; } // Up
|
||||
if (key.scancode == 0x50) { select_index(current + 1); return true; } // Down
|
||||
if (key.ascii == 'r' || key.ascii == 'R') {
|
||||
@@ -703,7 +811,10 @@ extern "C" void _start() {
|
||||
g_accent = mtk::load_system_accent();
|
||||
|
||||
g_is_admin = session_is_admin();
|
||||
if (g_is_admin) load_grants();
|
||||
if (g_is_admin) {
|
||||
load_grants();
|
||||
load_decisions();
|
||||
}
|
||||
|
||||
if (!g_win.create("Capabilities", WIN_W, WIN_H)) montauk::exit(1);
|
||||
render();
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -59,11 +59,10 @@ void launch_session(LoginState* ls) {
|
||||
// session is delegable for the same reason an admin one is -- otherwise
|
||||
// its own terminal could not hand the shell the suspend it already has.
|
||||
montauk::abi::SpawnCapabilities caps{};
|
||||
caps.permitted = montauk::user::is_admin(ls->username)
|
||||
caps.effective = montauk::user::is_admin(ls->username)
|
||||
? montauk::abi::CAP_ADMIN_SESSION
|
||||
: montauk::abi::CAP_STANDARD_SESSION;
|
||||
caps.effective = caps.permitted;
|
||||
caps.delegable = caps.permitted;
|
||||
caps.delegable = caps.effective;
|
||||
|
||||
int pid;
|
||||
if (ls->session_mode == SESSION_CONSOLE) {
|
||||
@@ -74,7 +73,6 @@ void launch_session(LoginState* ls) {
|
||||
} else {
|
||||
// The desktop is also the compositor. That authority is not the
|
||||
// session's to pass on, so it is added here and not delegable.
|
||||
caps.permitted |= montauk::abi::CAP_COMPOSITOR;
|
||||
caps.effective |= montauk::abi::CAP_COMPOSITOR;
|
||||
pid = montauk::spawn_with_caps("0:/os/desktop.elf", ls->username,
|
||||
ls->username, caps);
|
||||
|
||||
@@ -27,14 +27,13 @@ static void maybe_run_setup_session() {
|
||||
|
||||
montauk::abi::SpawnCapabilities caps{};
|
||||
bool admin = montauk::streq(role, "admin");
|
||||
caps.permitted = caps.effective = admin
|
||||
caps.effective = admin
|
||||
? montauk::abi::CAP_ADMIN_SESSION
|
||||
: montauk::abi::CAP_STANDARD_SESSION;
|
||||
// Matches launch_session(): a session delegates from what it holds, and
|
||||
// the grant table decides which program receives which part of it.
|
||||
caps.delegable = caps.permitted;
|
||||
caps.delegable = caps.effective;
|
||||
// Compositor authority for the desktop itself, never delegable onwards.
|
||||
caps.permitted |= montauk::abi::CAP_COMPOSITOR;
|
||||
caps.effective |= montauk::abi::CAP_COMPOSITOR;
|
||||
int pid = montauk::spawn_with_caps("0:/os/desktop.elf", user, user, caps);
|
||||
if (pid >= 0) {
|
||||
|
||||
Reference in new issue
Block a user