diff --git a/docs/consent.md b/docs/consent.md new file mode 100644 index 0000000..dcd17ca --- /dev/null +++ b/docs/consent.md @@ -0,0 +1,178 @@ +# Consent Prompts + +A program that has no entry in `0:/config/capabilities.toml` starts with no +capabilities. For five of them, it can ask the user the first time it needs +one, instead of failing. This works much like TCC on macOS: the desktop shows +a prompt. For installed programs the answer is remembered; for any other +binary it lasts until the process exits. + +| Capability | The prompt says the app wants to... | +|-----------------|-----------------------------------------------------------------------| +| `storage_admin` | format, partition and mount disks | +| `network_admin` | change network settings and join Wi-Fi networks | +| `set_time` | change the system clock and time zone | +| `display_admin` | change the screen resolution and brightness | +| `log_read` | read the system log, which can reveal what other apps and devices are doing | + +No other capability can be asked for. The eligible set is +`CAP_CONSENT_ELIGIBLE` in `Api/Syscall.hpp`. + +## How a request flows + +1. A syscall gated on an eligible capability calls + `Sched::Consent::Require()` (`kernel/src/Sched/Consent.cpp`) instead of + `HasCapability()`. All of these checks sit at the top of their syscall in + `Api/Syscall.cpp`, where the thread holds no locks and may block. +2. If the caller lacks the capability and is allowed to ask (see below), the + kernel queues a request and blocks the calling thread. A second thread of + the same process asking for the same capability waits on the same request. +3. The session's consent agent, the desktop, picks the request up with + `SYS_CONSENT_NEXT` from its main loop. If `0:/config/consent.toml` already + holds an answer for this program and capability, the desktop replies + without showing anything. Otherwise it opens the prompt. +4. `SYS_CONSENT_REPLY` either grants the capability or records a denial. The + waiting thread wakes, and its syscall proceeds or returns + `SYS_ERR_PERMISSION`. + +If nobody answers within `CONSENT_TIMEOUT_MS` (120 s), the request is denied. +If the agent exits, every pending request is denied. If the app exits, its +requests are cancelled. + +## Who may ask + +A process may ask for a capability only when all of these hold: + +- The capability is within its **consent ceiling**: the part of its parent's + delegable set, recorded at spawn, that is eligible for consent. +- The session has a registered agent. Registering (`SYS_CONSENT_REGISTER`) + requires `CAP_COMPOSITOR`, so only the desktop can be the agent. +- The process is in the agent's session and is not the agent itself. +- The agent still holds the capability as delegable. +- It has not already been refused that capability during its lifetime. + +These rules have some consequences: + +- **Standard sessions never prompt.** A standard session holds none of the + eligible capabilities, so every ceiling under it is empty. +- **Console sessions and SSH sessions never prompt.** They have no agent + (sshd is not in the desktop's session), so a remote user cannot put a + prompt on the local screen. +- **A sandboxing launcher still works.** If a parent spawns a child with + nothing delegable, the child's ceiling is empty and it cannot ask. +- After a refusal the process gets `SYS_ERR_PERMISSION` immediately every + time, so it cannot flood the user with prompts. + +## Why this keeps the capability model's invariants + +1. **Authority only narrows down the process tree.** A grant never exceeds + the process's consent ceiling, which is exactly what its parent could have + given it at spawn. The grant must also be within the agent's own delegable + set. Consent-granted capabilities are added only to the effective set, + never to the delegable set, so a consented app cannot pass them on to + anything it launches. Consent delays a delegation the parent could already + have made; it never exceeds one. The kernel re-checks all of this under + the scheduler lock when it applies the grant, so a grant can never land on + a process slot that has exited and been reused. +2. **Capabilities are kernel-owned.** The kernel identifies the program by + the path it loaded, never by a user name. + +## Program identity + +A stored answer is keyed on the program's path, and a path identifies a +program only if nobody else can replace the file. On the system volume, +writing anything under `0:/os` or `0:/apps` requires `CAP_SYSTEM_IMAGE` +(`Fs/ProtectedPaths.cpp`), which no session holds. So a program there is +**installed**, and its path is a trustworthy identity. + +The kernel decides this, not the desktop: each `ConsentRequest` carries +`imageProtected`, computed from the resolved image path with the same rule +that guards writes. Paths are normalized by the same resolver the write check +uses, so `0:/os/../users/x.elf` is not mistaken for an installed program. + +Any other binary, such as one in a user's home directory or on a data volume, +could be swapped for another at the same path. Its answers are never stored: +it is asked each time it runs, and the answer lasts until that process exits. + +The same rule applies to static grants. `Sched::Spawn` starts any image +outside `0:/os` and `0:/apps` with no capabilities, whatever its launcher +asked for, and logs a warning. Otherwise a program with no authority could +overwrite a granted binary in, say, a home directory, and then run with the +grant the next time the shell or desktop launched it. A program installed +elsewhere gets its authority through consent instead. The Capabilities app +flags such grants. + +## The prompt + +The prompt is a normal window inside the desktop, not a full-screen overlay. +It is placed over the requesting app's window when that app has one, and at +the centre of the screen otherwise. It shows: + +- the app's name: its menu label when the path belongs to an installed app, + otherwise the file name +- what it wants to do +- the full path + +The prompt window belongs to the desktop itself (`programs/src/desktop/consent.cpp`), +not to the window server, so no process can map its pixels or send it input. +`SYS_WINMAP` and `SYS_WINSENDEVENT` require `CAP_COMPOSITOR` anyway. This +leaves clickjacking, where another window is raised over the prompt or steals +focus just as the user clicks. To prevent that, **Allow** stays disabled +until the prompt has been focused and on top for 700 ms without interruption. +Losing focus or being covered restarts the delay. + +| Action | Result | Saved? | +|--------------------------------|---------------------------------|--------| +| Allow (click, once armed) | Granted | Installed programs only | +| Don't Allow (click, or Return once armed) | Denied | Installed programs only | +| Escape, or the title-bar close | Denied for this process only | No | +| Timeout, logout | Denied for this process only | No | + +For a program that is not installed, the prompt also says that the answer +applies only until the program exits. + +Only one prompt is shown at a time. Further requests wait in the kernel's +queue, which holds up to 16 requests. + +## Stored decisions + +Decisions are stored in `0:/config/consent.toml`: + +```toml +[decision.1] +path = "0:/apps/wifitool/wifitool.elf" +capability = "network_admin" +decision = "allow" +``` + +A decision applies when the path and the capability match. Only installed +programs are ever stored, and the desktop looks up only requests the kernel +marked `imageProtected`. A new answer for the same path and capability +replaces the old one. Updating an installed program keeps its decisions, +because installing it already required `CAP_SYSTEM_IMAGE`. The file is a +protected path that requires `CAP_USER_ADMIN` to write, the same as +`capabilities.toml`: anything that can edit it could grant itself access. + +The **Consent** tab of the Capabilities app lists the stored decisions and can +**Forget** one, so the program is asked again next time. A copy that is +already running keeps a granted capability until it exits. + +## Syscalls + +| Number | Name | Caller | Purpose | +|--------|------------------------|--------|---------------------------------------------| +| 191 | `SYS_CONSENT_REGISTER` | agent | Become the session's consent agent (needs `CAP_COMPOSITOR`) | +| 192 | `SYS_CONSENT_NEXT` | agent | Take the oldest queued request (`ConsentRequest`) | +| 193 | `SYS_CONSENT_REPLY` | agent | `CONSENT_ALLOW` / `CONSENT_DENY` a delivered request | + +## Not covered yet + +- **Elevation for standard users**, for example entering an administrator's + password in the prompt. This needs a different source of authority, because + a standard session has nothing to delegate. +- **Console sessions.** A text prompt in the terminal could be faked by + anything that prints to it. +- **Per-user decisions.** The table is system-wide, like the grants. +- **Revoking from a running process.** Forgetting a decision only affects + the next launch. +- **App-supplied reasons.** The prompt describes the capability in the + system's own words. A program cannot add text of its own. diff --git a/kernel/src/Api/BuildNo.hpp b/kernel/src/Api/BuildNo.hpp index 9e15c66..2fb68a4 100644 --- a/kernel/src/Api/BuildNo.hpp +++ b/kernel/src/Api/BuildNo.hpp @@ -12,4 +12,4 @@ #pragma once -#define MONTAUK_BUILD_NUMBER 239 +#define MONTAUK_BUILD_NUMBER 251 diff --git a/kernel/src/Api/Process.hpp b/kernel/src/Api/Process.hpp index e477ba0..26eeaae 100644 --- a/kernel/src/Api/Process.hpp +++ b/kernel/src/Api/Process.hpp @@ -144,7 +144,6 @@ namespace montauk::abi { static int Sys_GetCaps(SpawnCapabilities* out) { auto* proc = Sched::GetCurrentProcessPtr(); if (proc == nullptr || out == nullptr) return -1; - out->permitted = proc->permittedCaps; out->effective = proc->effectiveCaps; out->delegable = proc->delegableCaps; return 0; @@ -214,7 +213,6 @@ namespace montauk::abi { } buf[count].heapUsed = Sched::g_allocatedPages[i] * 0x1000; buf[count].cpuTimeMs = proc->cpuTimeMs; - buf[count].permittedCaps = proc->permittedCaps; buf[count].effectiveCaps = proc->effectiveCaps; buf[count].delegableCaps = proc->delegableCaps; count++; diff --git a/kernel/src/Api/Syscall.cpp b/kernel/src/Api/Syscall.cpp index 4d26c2c..30c93bc 100644 --- a/kernel/src/Api/Syscall.cpp +++ b/kernel/src/Api/Syscall.cpp @@ -11,6 +11,7 @@ /* For common functions used by multiple syscall implementations*/ #include "Common.hpp" +#include /* Syscall impl. includes */ #include "Process.hpp" // SYS_EXIT, SYS_YIELD, SYS_SLEEP_MS, SYS_GETPID, SYS_WAITPID, SYS_SPAWN, SYS_GETARGS, SYS_PROCLIST, SYS_KILL @@ -200,11 +201,11 @@ namespace montauk::abi { (uint64_t)frame->arg2 * sizeof(DisplayModeInfo), true)) return -1; return Sys_DisplayModes((DisplayModeInfo*)frame->arg1, (int)frame->arg2); case SYS_DISPLAYSETMODE: - if (!Sched::HasCapability(CAP_DISPLAY_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_DISPLAY_ADMIN)) return SYS_ERR_PERMISSION; return Sys_DisplaySetMode((int)frame->arg1); case SYS_DISPLAYBRIGHTNESS: if ((int64_t)frame->arg1 >= 0 && - !Sched::HasCapability(CAP_DISPLAY_ADMIN)) return SYS_ERR_PERMISSION; + !Sched::Consent::Require(CAP_DISPLAY_ADMIN)) return SYS_ERR_PERMISSION; return Sys_DisplayBrightness((int)frame->arg1); case SYS_GETEXECPATH: if (!UserMemory::Range(frame->arg2 ? frame->arg1 : frame->arg1, frame->arg2, true)) return -1; @@ -248,7 +249,7 @@ namespace montauk::abi { Sys_GetTime((DateTime*)frame->arg1); return 0; case SYS_SETUNIXTIME: - if (!Sched::HasCapability(CAP_SET_TIME)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_SET_TIME)) return SYS_ERR_PERMISSION; return Sys_SetUnixTime((int64_t)frame->arg1); case SYS_SOCKET: return (int64_t)Sys_Socket((int)frame->arg1); @@ -274,7 +275,7 @@ namespace montauk::abi { Sys_GetNetCfg((NetCfg*)frame->arg1); return 0; case SYS_SETNETCFG: - if (!Sched::HasCapability(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; if (!UserMemory::Readable(frame->arg1)) return -1; return (int64_t)Sys_SetNetCfg((const NetCfg*)frame->arg1); case SYS_NETSTATUS: @@ -330,7 +331,7 @@ namespace montauk::abi { if (!UserMemory::Range(frame->arg1, frame->arg2, true)) return -1; return Sys_GetRandom((uint8_t*)frame->arg1, frame->arg2); case SYS_LOG: - if (!Sched::HasCapability(CAP_LOG_READ)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_LOG_READ)) return SYS_ERR_PERMISSION; if (!UserMemory::Range(frame->arg1, frame->arg2, true)) return -1; return Kt::ReadKernelLogBuffer((char*)frame->arg1, frame->arg2); case SYS_MOUSESTATE: @@ -438,20 +439,20 @@ namespace montauk::abi { return (int64_t)Sys_DiskWrite((int)frame->arg1, frame->arg2, (uint32_t)frame->arg3, (const void*)frame->arg4); case SYS_GPTINIT: - if (!Sched::HasCapability(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; return (int64_t)Sys_GptInit((int)frame->arg1); case SYS_GPTADD: - if (!Sched::HasCapability(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; if (!UserMemory::Readable(frame->arg1)) return -1; return (int64_t)Sys_GptAdd((const GptAddParams*)frame->arg1); case SYS_FSMOUNT: - if (!Sched::HasCapability(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; return (int64_t)Sys_FsMount((int)frame->arg1, (int)frame->arg2); case SYS_FS_SYNC: - if (!Sched::HasCapability(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; return Sys_FsSync(); case SYS_FSFORMAT: - if (!Sched::HasCapability(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_STORAGE_ADMIN)) return SYS_ERR_PERMISSION; if (!UserMemory::Readable(frame->arg1)) return -1; return (int64_t)Sys_FsFormat((const FsFormatParams*)frame->arg1); case SYS_AUDIOOPEN: @@ -517,6 +518,13 @@ namespace montauk::abi { case SYS_GETCAPS: if (!UserMemory::Writable(frame->arg1)) return -1; return Sys_GetCaps((SpawnCapabilities*)frame->arg1); + case SYS_CONSENT_REGISTER: + return Sched::Consent::Register(); + case SYS_CONSENT_NEXT: + if (!UserMemory::Writable(frame->arg1)) return -1; + return Sched::Consent::Next((ConsentRequest*)frame->arg1); + case SYS_CONSENT_REPLY: + return Sched::Consent::Reply(frame->arg1, (int)frame->arg2); case SYS_THREAD_SPAWN: return Sys_ThreadSpawn(frame->arg1, frame->arg2, frame->arg3); case SYS_THREAD_EXIT: @@ -560,7 +568,7 @@ namespace montauk::abi { if (!UserMemory::Writable(frame->arg1)) return -1; return Sys_BtInfo((BtAdapterInfo*)frame->arg1); case SYS_WIFI_SCAN: - if (!Sched::HasCapability(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; if ((int64_t)frame->arg2 < 0) return -1; if (!UserMemory::Range(frame->arg1, (uint64_t)frame->arg2 * sizeof(WifiNetwork), true)) return -1; return Sys_WifiScan((WifiNetwork*)frame->arg1, (int)frame->arg2, (uint32_t)frame->arg3); @@ -568,22 +576,22 @@ namespace montauk::abi { if (!UserMemory::Writable(frame->arg1)) return -1; return Sys_WifiInfo((WifiInfo*)frame->arg1); case SYS_WIFI_CONNECT: - if (!Sched::HasCapability(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; if (!UserMemory::String(frame->arg1, 64)) return -1; if (frame->arg2 != 0 && !UserMemory::String(frame->arg2, 128)) return -1; return Sys_WifiConnect((const char*)frame->arg1, (const char*)frame->arg2); case SYS_WIFI_DISCONNECT: - if (!Sched::HasCapability(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; return Sys_WifiDisconnect(); case SYS_WIFI_SCAN_START: - if (!Sched::HasCapability(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; return Sys_WifiScanStart((uint32_t)frame->arg1); case SYS_WIFI_RESULTS: if ((int64_t)frame->arg2 < 0) return -1; if (!UserMemory::Range(frame->arg1, (uint64_t)frame->arg2 * sizeof(WifiNetwork), true)) return -1; return Sys_WifiResults((WifiNetwork*)frame->arg1, (int)frame->arg2); case SYS_WIFI_CONNECT_ASYNC: - if (!Sched::HasCapability(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_NETWORK_ADMIN)) return SYS_ERR_PERMISSION; if (!UserMemory::String(frame->arg1, 64)) return -1; if (frame->arg2 != 0 && !UserMemory::String(frame->arg2, 128)) return -1; return Sys_WifiConnectAsync((const char*)frame->arg1, (const char*)frame->arg2); @@ -595,7 +603,7 @@ namespace montauk::abi { if (!Sched::HasCapability(CAP_SUSPEND)) return SYS_ERR_PERMISSION; return Sys_Suspend(); case SYS_SETTZ: - if (!Sched::HasCapability(CAP_SET_TIME)) return SYS_ERR_PERMISSION; + if (!Sched::Consent::Require(CAP_SET_TIME)) return SYS_ERR_PERMISSION; return Sys_SetTZ((int32_t)frame->arg1); case SYS_GETTZ: return Sys_GetTZ(); diff --git a/kernel/src/Api/Syscall.hpp b/kernel/src/Api/Syscall.hpp index f6f7db2..fbbcbbe 100644 --- a/kernel/src/Api/Syscall.hpp +++ b/kernel/src/Api/Syscall.hpp @@ -352,6 +352,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; @@ -396,26 +403,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]; + }; // Generic USB errors. Claims are restricted to interfaces without a @@ -741,7 +777,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; }; diff --git a/kernel/src/Fs/ProtectedPaths.cpp b/kernel/src/Fs/ProtectedPaths.cpp index e448f2a..56949de 100644 --- a/kernel/src/Fs/ProtectedPaths.cpp +++ b/kernel/src/Fs/ProtectedPaths.cpp @@ -28,6 +28,7 @@ namespace Fs { {"/config/init.toml", false, montauk::abi::CAP_USER_ADMIN}, {"/config/ssh.toml", false, montauk::abi::CAP_USER_ADMIN}, {"/config/capabilities.toml",false, montauk::abi::CAP_USER_ADMIN}, + {"/config/consent.toml", false, montauk::abi::CAP_USER_ADMIN}, {"/config/wallpaper.cache", false, montauk::abi::CAP_USER_ADMIN}, // Read by the Bluetooth driver at controller bring-up. {"/config/bluetooth.toml", false, montauk::abi::CAP_DEVICE_ADMIN}, @@ -87,6 +88,10 @@ namespace Fs { return required; } + bool IsSystemImage(const char* path) { + return (RequiredFileWriteCapability(path) & montauk::abi::CAP_SYSTEM_IMAGE) != 0; + } + void LogProtectedPaths() { for (const auto& rule : g_protectedPaths) { Kt::KernelLogStream(Kt::INFO, "IPC") << "Protected path " diff --git a/kernel/src/Fs/ProtectedPaths.hpp b/kernel/src/Fs/ProtectedPaths.hpp index fc5281c..3846055 100644 --- a/kernel/src/Fs/ProtectedPaths.hpp +++ b/kernel/src/Fs/ProtectedPaths.hpp @@ -13,6 +13,11 @@ namespace Fs { // re-timestamp `path`. Returns 0 when the path is unprotected. uint64_t RequiredFileWriteCapability(const char* path); + // True for an installed program image: one on the system volume that + // cannot be replaced without CAP_SYSTEM_IMAGE (0:/os, 0:/apps). Only such + // an image is identified by its path well enough to carry a grant. + bool IsSystemImage(const char* path); + // Log the rule table at boot, so a path that should be protected and is // not is visible rather than silently missing. void LogProtectedPaths(); diff --git a/kernel/src/Sched/Consent.cpp b/kernel/src/Sched/Consent.cpp new file mode 100644 index 0000000..7d075c9 --- /dev/null +++ b/kernel/src/Sched/Consent.cpp @@ -0,0 +1,302 @@ +/* + * Consent.cpp + * First-use consent prompts for eligible capabilities + * Copyright (c) 2026 Daniel Hammer +*/ + +#include "Consent.hpp" +#include +#include +#include +#include +#include + +// 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 state{StateFree}; + std::atomic 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"; + } + } + +} diff --git a/kernel/src/Sched/Consent.hpp b/kernel/src/Sched/Consent.hpp new file mode 100644 index 0000000..7ec0a85 --- /dev/null +++ b/kernel/src/Sched/Consent.hpp @@ -0,0 +1,34 @@ +/* + * Consent.hpp + * First-use consent prompts for eligible capabilities + * Copyright (c) 2026 Daniel Hammer +*/ + +#pragma once +#include +#include +#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); + +} diff --git a/kernel/src/Sched/Scheduler.cpp b/kernel/src/Sched/Scheduler.cpp index 1395a80..2908bba 100644 --- a/kernel/src/Sched/Scheduler.cpp +++ b/kernel/src/Sched/Scheduler.cpp @@ -7,6 +7,8 @@ #include #include "Scheduler.hpp" #include "ElfLoader.hpp" +#include "Consent.hpp" +#include #include #include #include @@ -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]; diff --git a/kernel/src/Sched/Scheduler.hpp b/kernel/src/Sched/Scheduler.hpp index c1e6953..6259827 100644 --- a/kernel/src/Sched/Scheduler.hpp +++ b/kernel/src/Sched/Scheduler.hpp @@ -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 diff --git a/programs/data/config/capabilities.toml b/programs/data/config/capabilities.toml index 28d9f14..4e33fe0 100644 --- a/programs/data/config/capabilities.toml +++ b/programs/data/config/capabilities.toml @@ -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"] diff --git a/programs/include/Api/Syscall.hpp b/programs/include/Api/Syscall.hpp index b69e418..d07253c 100644 --- a/programs/include/Api/Syscall.hpp +++ b/programs/include/Api/Syscall.hpp @@ -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; }; diff --git a/programs/include/libc/montauk.h b/programs/include/libc/montauk.h index d0187fa..fc947f4 100644 --- a/programs/include/libc/montauk.h +++ b/programs/include/libc/montauk.h @@ -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 diff --git a/programs/include/montauk/capabilities.h b/programs/include/montauk/capabilities.h index 1a851c9..bbf0595 100644 --- a/programs/include/montauk/capabilities.h +++ b/programs/include/montauk/capabilities.h @@ -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; } diff --git a/programs/include/montauk/consent.h b/programs/include/montauk/consent.h new file mode 100644 index 0000000..b8b82d3 --- /dev/null +++ b/programs/include/montauk/consent.h @@ -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 +#include +#include +#include +#include + +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 " 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..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 diff --git a/programs/include/montauk/syscall.h b/programs/include/montauk/syscall.h index a019dfe..eee411f 100644 --- a/programs/include/montauk/syscall.h +++ b/programs/include/montauk/syscall.h @@ -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); } diff --git a/programs/src/capabilities/main.cpp b/programs/src/capabilities/main.cpp index df51796..a35fd31 100644 --- a/programs/src/capabilities/main.cpp +++ b/programs/src/capabilities/main.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -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(); diff --git a/programs/src/desktop/Makefile b/programs/src/desktop/Makefile index c793e99..4760605 100644 --- a/programs/src/desktop/Makefile +++ b/programs/src/desktop/Makefile @@ -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)) diff --git a/programs/src/desktop/consent.cpp b/programs/src/desktop/consent.cpp new file mode 100644 index 0000000..0ba3b46 --- /dev/null +++ b/programs/src/desktop/consent.cpp @@ -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 +#include + +// 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; + } +} diff --git a/programs/src/desktop/desktop_internal.hpp b/programs/src/desktop/desktop_internal.hpp index d939009..68658fa 100644 --- a/programs/src/desktop/desktop_internal.hpp +++ b/programs/src/desktop/desktop_internal.hpp @@ -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); diff --git a/programs/src/desktop/main.cpp b/programs/src/desktop/main.cpp index c12f933..5655343 100644 --- a/programs/src/desktop/main.cpp +++ b/programs/src/desktop/main.cpp @@ -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++) { diff --git a/programs/src/login/login_input.cpp b/programs/src/login/login_input.cpp index 0e7e9d9..508620f 100644 --- a/programs/src/login/login_input.cpp +++ b/programs/src/login/login_input.cpp @@ -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); diff --git a/programs/src/login/main.cpp b/programs/src/login/main.cpp index 2ea5bbe..7b504d4 100644 --- a/programs/src/login/main.cpp +++ b/programs/src/login/main.cpp @@ -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) { diff --git a/scripts/copy_icons.sh b/scripts/copy_icons.sh index da99ecc..4ec8a74 100755 --- a/scripts/copy_icons.sh +++ b/scripts/copy_icons.sh @@ -155,6 +155,10 @@ ICONS=( "status/symbolic/audio-volume-high-symbolic.svg" "apps/scalable/gnome-logout.svg" "apps/scalable/lock.svg" + # Consent prompt, one per capability (see montauk/consent.h). The clock, + # network and disk icons are already staged above. + "apps/scalable/preferences-desktop-display.svg" + "categories/scalable/utilities-log-viewer.svg" "apps/scalable/sleep.svg" "apps/scalable/kolourpaint.svg" # paint app "panel/sensors-temperature-symbolic.svg" # temperature panel icon