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

+302
View File
@@ -0,0 +1,302 @@
/*
* Consent.cpp
* First-use consent prompts for eligible capabilities
* Copyright (c) 2026 Daniel Hammer
*/
#include "Consent.hpp"
#include <atomic>
#include <Libraries/Memory.hpp>
#include <Terminal/Terminal.hpp>
#include <CppLib/Stream.hpp>
#include <CppLib/Spinlock.hpp>
// A process that lacks an eligible capability can ask the user for it the
// first time a syscall needs it. The request goes to the session's consent
// agent (the desktop, which holds CAP_COMPOSITOR), which answers from its
// stored decisions or by showing a prompt.
//
// This keeps both capability invariants:
// 1. Authority only narrows down the process tree. A grant is bounded by the
// process's consent ceiling -- what its parent could have delegated to it
// at spawn -- and by the agent's own delegable set, and is only ever added
// to the effective set. Consent defers a delegation; it never exceeds one.
// 2. Capabilities are kernel-owned. The kernel identifies the program by the
// path it loaded, never by a user name, and tells the agent whether that
// path is protected by CAP_SYSTEM_IMAGE (only then may it be remembered).
//
// Lock order: g_lock, then the scheduler lock (inside the Sched helpers).
// Nothing takes g_lock while holding the scheduler lock; the wait predicate
// that runs under the scheduler lock only reads atomics.
namespace Sched::Consent {
using namespace montauk::abi;
namespace {
struct EligibleCap {
const char* name;
uint64_t bit;
};
// Names for the kernel log.
constexpr EligibleCap kEligible[] = {
{"storage_admin", CAP_STORAGE_ADMIN},
{"network_admin", CAP_NETWORK_ADMIN},
{"set_time", CAP_SET_TIME},
{"display_admin", CAP_DISPLAY_ADMIN},
{"log_read", CAP_LOG_READ},
};
constexpr uint64_t EligibleMask() {
uint64_t mask = 0;
for (const auto& cap : kEligible) mask |= cap.bit;
return mask;
}
static_assert(EligibleMask() == CAP_CONSENT_ELIGIBLE);
constexpr int MaxRequests = 16;
enum : uint8_t { StateFree = 0, StateQueued = 1, StateDelivered = 2 };
// Waiters block on the Request object itself. Every path that retires
// a request (reply, timeout, cancellation) wakes it, so no waiter is
// ever left blocked on a slot that has been reused.
struct Request {
std::atomic<uint8_t> state{StateFree};
std::atomic<uint64_t> id{0};
int pid = -1;
uint64_t capability = 0;
};
Request g_requests[MaxRequests];
kcp::Spinlock g_lock;
int g_agentPid = -1;
uint64_t g_nextId = 1;
struct WaitContext {
Request* request;
uint64_t id;
};
bool StillPending(void* opaque) {
auto* ctx = (WaitContext*)opaque;
return ctx->request->state.load(std::memory_order_acquire) != StateFree &&
ctx->request->id.load(std::memory_order_acquire) == ctx->id;
}
const char* NameOf(uint64_t capability) {
for (const auto& cap : kEligible) {
if (cap.bit == capability) return cap.name;
}
return "?";
}
void RetireLocked(Request& request) {
request.state.store(StateFree, std::memory_order_release);
request.pid = -1;
request.capability = 0;
}
} // namespace
bool Require(uint64_t capability) {
if (HasCapability(capability)) return true;
// Exactly one eligible bit.
if ((capability & ~CAP_CONSENT_ELIGIBLE) != 0 ||
capability == 0 || (capability & (capability - 1)) != 0) {
return false;
}
Process* proc = GetCurrentProcessPtr();
if (proc == nullptr) return false;
int pid = proc->pid;
Request* request = nullptr;
uint64_t id = 0;
bool fresh = false;
g_lock.Acquire();
if (g_agentPid >= 0 && ConsentSubjectEligible(pid, g_agentPid, capability)) {
// A second thread asking for the same thing waits on the same prompt.
for (auto& r : g_requests) {
if (r.state.load(std::memory_order_relaxed) != StateFree &&
r.pid == pid && r.capability == capability) {
request = &r;
break;
}
}
if (request == nullptr) {
for (auto& r : g_requests) {
if (r.state.load(std::memory_order_relaxed) == StateFree) {
request = &r;
break;
}
}
if (request != nullptr) {
request->pid = pid;
request->capability = capability;
request->id.store(g_nextId++, std::memory_order_release);
request->state.store(StateQueued, std::memory_order_release);
fresh = true;
}
}
if (request != nullptr) id = request->id.load(std::memory_order_relaxed);
}
g_lock.Release();
if (request == nullptr) return false;
if (fresh) {
Kt::KernelLogStream(Kt::INFO, "Consent") << "pid " << (uint64_t)pid
<< " (" << proc->imagePath << ") asks for " << NameOf(capability);
}
WaitContext ctx{request, id};
BlockOnObjectIf(request, PromptTimeoutMs, StillPending, &ctx);
// Woken by a reply, a cancellation, or the timeout. Only the last
// leaves the request live; retire it so the agent's late answer is
// refused as stale, and release anyone else waiting on it.
bool timedOut = false;
g_lock.Acquire();
if (request->state.load(std::memory_order_relaxed) != StateFree &&
request->id.load(std::memory_order_relaxed) == id) {
RetireLocked(*request);
timedOut = true;
}
g_lock.Release();
if (timedOut) {
WakeObjectWaiters(request);
Kt::KernelLogStream(Kt::INFO, "Consent") << "pid " << (uint64_t)pid
<< ": no answer for " << NameOf(capability) << ", denied";
}
return HasCapability(capability);
}
int Register() {
if (!HasCapability(CAP_COMPOSITOR)) return SYS_ERR_PERMISSION;
int pid = GetCurrentPid();
g_lock.Acquire();
// OnProcessExit clears a dead agent, so a live one is always current.
if (g_agentPid >= 0 && g_agentPid != pid) {
g_lock.Release();
return -1;
}
g_agentPid = pid;
g_lock.Release();
Kt::KernelLogStream(Kt::INFO, "Consent") << "Agent registered: pid " << (uint64_t)pid;
return 0;
}
int Next(ConsentRequest* out) {
int agent = GetCurrentPid();
if (agent < 0 || agent != g_agentPid) return SYS_ERR_PERMISSION;
ConsentRequest local;
for (;;) {
memset(&local, 0, sizeof(local));
Request* picked = nullptr;
bool found = false;
bool subjectGone = false;
g_lock.Acquire();
for (auto& r : g_requests) {
if (r.state.load(std::memory_order_relaxed) != StateQueued) continue;
if (picked == nullptr ||
r.id.load(std::memory_order_relaxed) <
picked->id.load(std::memory_order_relaxed)) {
picked = &r;
}
}
if (picked != nullptr) {
local.id = picked->id.load(std::memory_order_relaxed);
local.pid = picked->pid;
local.capability = picked->capability;
if (SnapshotConsentSubject(picked->pid, local)) {
picked->state.store(StateDelivered, std::memory_order_release);
found = true;
} else {
RetireLocked(*picked);
subjectGone = true;
}
}
g_lock.Release();
if (subjectGone) {
WakeObjectWaiters(picked);
continue;
}
if (!found) return 0;
// Copy out only after dropping the lock: the agent's buffer may
// need a demand-paging fault.
*out = local;
return 1;
}
}
int Reply(uint64_t id, int decision) {
int agent = GetCurrentPid();
if (agent < 0 || agent != g_agentPid) return SYS_ERR_PERMISSION;
Request* request = nullptr;
int pid = -1;
uint64_t capability = 0;
bool granted = false;
g_lock.Acquire();
for (auto& r : g_requests) {
if (r.state.load(std::memory_order_relaxed) == StateDelivered &&
r.id.load(std::memory_order_relaxed) == id) {
request = &r;
break;
}
}
if (request != nullptr) {
pid = request->pid;
capability = request->capability;
// Apply before retiring, so a waiter that wakes sees the result.
if (decision == CONSENT_ALLOW)
granted = ApplyConsentGrant(pid, agent, capability);
else
ApplyConsentDenial(pid, capability);
RetireLocked(*request);
}
g_lock.Release();
if (request == nullptr) return -1;
WakeObjectWaiters(request);
Kt::KernelLogStream(Kt::INFO, "Consent") << "pid " << (uint64_t)pid << " "
<< NameOf(capability) << ": "
<< (granted ? "allowed" : (decision == CONSENT_ALLOW ? "allow refused (no longer valid)" : "denied"));
return 0;
}
void OnProcessExit(int pid) {
Request* retired[MaxRequests];
int count = 0;
g_lock.Acquire();
bool agentExited = pid == g_agentPid;
if (agentExited) g_agentPid = -1;
for (auto& r : g_requests) {
if (r.state.load(std::memory_order_relaxed) == StateFree) continue;
if (!agentExited && r.pid != pid) continue;
RetireLocked(r);
retired[count++] = &r;
}
g_lock.Release();
for (int i = 0; i < count; i++) WakeObjectWaiters(retired[i]);
if (agentExited) {
Kt::KernelLogStream(Kt::INFO, "Consent") << "Agent pid " << (uint64_t)pid
<< " exited; " << (uint64_t)count << " pending request(s) denied";
}
}
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Consent.hpp
* First-use consent prompts for eligible capabilities
* Copyright (c) 2026 Daniel Hammer
*/
#pragma once
#include <cstdint>
#include <Api/Syscall.hpp>
#include "Scheduler.hpp"
namespace Sched::Consent {
// How long a thread waits for the user before the request is denied
// (not remembered, so the app may ask again later).
static constexpr uint64_t PromptTimeoutMs = montauk::abi::CONSENT_TIMEOUT_MS;
// Syscall-entry capability check. Returns true at once when the caller
// holds `capability`. Otherwise, when it is eligible for consent, blocks
// the calling thread until the session's agent answers or the prompt
// times out, and returns whether the capability is now held. Only call
// this where a thread may block: at the top of a syscall, with no locks.
bool Require(uint64_t capability);
// Agent side (SYS_CONSENT_*).
int Register();
int Next(montauk::abi::ConsentRequest* out);
int Reply(uint64_t id, int decision);
// Process teardown: cancel the exiting process's requests and, when it
// is the agent, every request (their waiters see a denial).
void OnProcessExit(int pid);
}
+101 -4
View File
@@ -7,6 +7,8 @@
#include <Memory/UserRange.hpp>
#include "Scheduler.hpp"
#include "ElfLoader.hpp"
#include "Consent.hpp"
#include <Fs/ProtectedPaths.hpp>
#include <Memory/PageFrameAllocator.hpp>
#include <Memory/Paging.hpp>
#include <Memory/HHDM.hpp>
@@ -299,9 +301,10 @@ namespace Sched {
processTable[i].environment[0] = '\0';
processTable[i].environmentLength = 1;
processTable[i].user[0] = '\0';
processTable[i].permittedCaps = 0;
processTable[i].effectiveCaps = 0;
processTable[i].delegableCaps = 0;
processTable[i].consentCeiling = 0;
processTable[i].consentDenied = 0;
processTable[i].sessionId = -1;
processTable[i].cwd[0] = '\0';
processTable[i].runningOnCpu = -1;
@@ -349,6 +352,20 @@ namespace Sched {
const char* environment, uint32_t environmentLength,
const montauk::abi::SpawnCapabilities* capabilities,
const char* userOverride) {
// Grants are keyed on the image path, so they are only as trustworthy
// as the path: anything outside 0:/os and 0:/apps can be replaced by
// any process, which would then run with the grant the next time a
// privileged launcher starts it. Such a program starts with nothing
// and asks through consent instead.
montauk::abi::SpawnCapabilities clamped{0, 0};
if (capabilities != nullptr &&
(capabilities->effective | capabilities->delegable) != 0 &&
!Fs::IsSystemImage(vfsPath)) {
Kt::KernelLogStream(Kt::WARNING, "Sched") << "Not an installed program, "
<< "starting without capabilities: " << vfsPath;
capabilities = &clamped;
}
schedLock.Acquire();
int slot = -1;
@@ -558,11 +575,9 @@ namespace Sched {
// must use SYS_SPAWN_CAPS for an explicit, kernel-validated delegation.
if (parentPrimarySlot >= 0) {
if (capabilities != nullptr) {
proc.permittedCaps = capabilities->permitted;
proc.effectiveCaps = capabilities->effective;
proc.delegableCaps = capabilities->delegable;
} else {
proc.permittedCaps = 0;
proc.effectiveCaps = 0;
proc.delegableCaps = 0;
}
@@ -570,10 +585,21 @@ namespace Sched {
// The kernel-created init process is the root of the capability
// delegation tree. No userspace pathname or PID receives this
// treatment; it is reached only with no current parent process.
proc.permittedCaps = montauk::abi::CAP_ALL;
proc.effectiveCaps = montauk::abi::CAP_ALL;
proc.delegableCaps = montauk::abi::CAP_ALL;
}
// Consent can later grant the child exactly the eligible authority
// its parent could have delegated to it now.
proc.consentCeiling = (parentPrimarySlot >= 0)
? processTable[parentPrimarySlot].delegableCaps & montauk::abi::CAP_CONSENT_ELIGIBLE
: 0;
proc.consentDenied = 0;
{
int i = 0;
for (; i < (int)sizeof(proc.imagePath) - 1 && vfsPath[i]; i++)
proc.imagePath[i] = vfsPath[i];
proc.imagePath[i] = '\0';
}
// Inherit user string from parent, or default to "system" if no parent.
// An explicit override is accepted only through the validated
@@ -1638,6 +1664,10 @@ namespace Sched {
// Clean up any windows owned by this process
WinServer::CleanupProcess(exitingPid);
// Cancel this process's consent requests; if it was the consent
// agent, deny everything still waiting on it.
Consent::OnProcessExit(exitingPid);
// Release any mixer virtual streams owned by this process so the slot
// and its ring buffer don't leak when an app forgets to audio_close.
Drivers::Audio::Mixer::CleanupProcess(exitingPid);
@@ -2104,6 +2134,73 @@ namespace Sched {
return nullptr;
}
// ==== Consent support ====
static Process* FindLiveProcessLocked(int pid) {
if (pid < 0) return nullptr;
for (int i = 0; i < MaxProcesses; i++) {
Process& p = processTable[i];
if (p.primarySlot != i || p.pid != pid) continue;
if (p.state == ProcessState::Ready || p.state == ProcessState::Running ||
p.state == ProcessState::Blocked) {
return &p;
}
}
return nullptr;
}
static bool ConsentPairValidLocked(const Process* subject, const Process* agent,
uint64_t capability) {
return subject != nullptr && agent != nullptr && subject != agent &&
agent->sessionId >= 0 && subject->sessionId == agent->sessionId &&
(agent->effectiveCaps & montauk::abi::CAP_COMPOSITOR) != 0 &&
(agent->delegableCaps & capability) == capability &&
(subject->consentCeiling & capability) == capability;
}
bool ConsentSubjectEligible(int pid, int agentPid, uint64_t capability) {
schedLock.Acquire();
Process* subject = FindLiveProcessLocked(pid);
bool ok = ConsentPairValidLocked(subject, FindLiveProcessLocked(agentPid), capability) &&
(subject->consentDenied & capability) == 0;
schedLock.Release();
return ok;
}
bool ApplyConsentGrant(int pid, int agentPid, uint64_t capability) {
schedLock.Acquire();
Process* subject = FindLiveProcessLocked(pid);
bool ok = ConsentPairValidLocked(subject, FindLiveProcessLocked(agentPid), capability);
if (ok) {
// Usable, never delegable: a consented app cannot hand the
// authority on to anything it launches.
subject->effectiveCaps |= capability;
}
schedLock.Release();
return ok;
}
void ApplyConsentDenial(int pid, uint64_t capability) {
schedLock.Acquire();
if (Process* subject = FindLiveProcessLocked(pid))
subject->consentDenied |= capability;
schedLock.Release();
}
bool SnapshotConsentSubject(int pid, montauk::abi::ConsentRequest& out) {
schedLock.Acquire();
Process* subject = FindLiveProcessLocked(pid);
if (subject != nullptr) {
memcpy(out.imagePath, subject->imagePath, sizeof(out.imagePath));
out.imagePath[sizeof(out.imagePath) - 1] = '\0';
// Only an image that cannot be rewritten without CAP_SYSTEM_IMAGE
// is identified by its path well enough to remember an answer.
out.imageProtected = Fs::IsSystemImage(out.imagePath);
}
schedLock.Release();
return subject != nullptr;
}
Process* GetProcessSlot(int slot) {
if (slot < 0 || slot >= MaxProcesses) return nullptr;
return &processTable[slot];
+18 -1
View File
@@ -91,12 +91,16 @@ namespace Sched {
char environment[EnvironmentBytes]; // NUL-separated NAME=VALUE entries
uint32_t environmentLength;
char user[32]; // Owner user name (inherited from parent on spawn)
uint64_t permittedCaps; // Authority owned by this process
uint64_t effectiveCaps; // Authority currently usable by syscalls
uint64_t delegableCaps; // Authority this process may pass to children
int sessionId; // Process-session leader PID (inherited on spawn)
char cwd[256]; // Absolute current working directory
// First-use consent (Sched/Consent.cpp). Primary slot only.
uint64_t consentCeiling; // eligible caps the parent could have delegated at spawn
uint64_t consentDenied; // caps refused for the rest of this process's life
char imagePath[256]; // full resolved image path; name[] truncates
// Thread-local storage. fsBase is loaded into IA32_FS_BASE when
// this thread is dispatched (0 = no TLS). The tls* template fields
// live on the primary slot and describe PT_TLS so sibling threads
@@ -276,6 +280,19 @@ namespace Sched {
// Find a process by PID (returns nullptr if not found or not alive)
Process* GetProcessByPid(int pid);
// Consent support for Sched/Consent.cpp. Each validates the (subject,
// agent) pair and reads or mutates both under the scheduler lock, so a
// grant can never land on a slot that exited and was reused.
//
// The pair is valid when both are live, distinct, in the same session,
// the agent still holds CAP_COMPOSITOR and has `capability` delegable,
// and `capability` lies within the subject's consent ceiling.
bool ConsentSubjectEligible(int pid, int agentPid, uint64_t capability);
bool ApplyConsentGrant(int pid, int agentPid, uint64_t capability);
void ApplyConsentDenial(int pid, uint64_t capability);
// Fill imagePath/imageProtected for the request; false if pid is gone.
bool SnapshotConsentSubject(int pid, montauk::abi::ConsentRequest& out);
// Spawn the crashpad to report on a faulted process (pid `crasherPid`).
// Called by the exception handler when a process faults. If the crasher had
// a redirected console, the crashpad inherits its out-stream so it can print