feat: add permission consent prompts, drop permitted capability mask

This commit is contained in:
daniel committed 2026-09-30 08:32:48 +02:00
1 parent c54181103f
commit ae5e84592d
25 files changed
+1632 -94

No files matched your search

+44 -9
View File
@@ -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;
};
+3
View File
@@ -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
+31 -14
View File
@@ -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;
}
+209
View File
@@ -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
+11
View File
@@ -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);
}