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
+1629 -91

No files matched your search

+178
View File
@@ -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.
+1 -1
View File
@@ -12,4 +12,4 @@
#pragma once #pragma once
#define MONTAUK_BUILD_NUMBER 239 #define MONTAUK_BUILD_NUMBER 251
-2
View File
@@ -144,7 +144,6 @@ namespace montauk::abi {
static int Sys_GetCaps(SpawnCapabilities* out) { static int Sys_GetCaps(SpawnCapabilities* out) {
auto* proc = Sched::GetCurrentProcessPtr(); auto* proc = Sched::GetCurrentProcessPtr();
if (proc == nullptr || out == nullptr) return -1; if (proc == nullptr || out == nullptr) return -1;
out->permitted = proc->permittedCaps;
out->effective = proc->effectiveCaps; out->effective = proc->effectiveCaps;
out->delegable = proc->delegableCaps; out->delegable = proc->delegableCaps;
return 0; return 0;
@@ -214,7 +213,6 @@ namespace montauk::abi {
} }
buf[count].heapUsed = Sched::g_allocatedPages[i] * 0x1000; buf[count].heapUsed = Sched::g_allocatedPages[i] * 0x1000;
buf[count].cpuTimeMs = proc->cpuTimeMs; buf[count].cpuTimeMs = proc->cpuTimeMs;
buf[count].permittedCaps = proc->permittedCaps;
buf[count].effectiveCaps = proc->effectiveCaps; buf[count].effectiveCaps = proc->effectiveCaps;
buf[count].delegableCaps = proc->delegableCaps; buf[count].delegableCaps = proc->delegableCaps;
count++; count++;
+24 -16
View File
@@ -11,6 +11,7 @@
/* For common functions used by multiple syscall implementations*/ /* For common functions used by multiple syscall implementations*/
#include "Common.hpp" #include "Common.hpp"
#include <Sched/Consent.hpp>
/* Syscall impl. includes */ /* 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 #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; (uint64_t)frame->arg2 * sizeof(DisplayModeInfo), true)) return -1;
return Sys_DisplayModes((DisplayModeInfo*)frame->arg1, (int)frame->arg2); return Sys_DisplayModes((DisplayModeInfo*)frame->arg1, (int)frame->arg2);
case SYS_DISPLAYSETMODE: 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); return Sys_DisplaySetMode((int)frame->arg1);
case SYS_DISPLAYBRIGHTNESS: case SYS_DISPLAYBRIGHTNESS:
if ((int64_t)frame->arg1 >= 0 && 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); return Sys_DisplayBrightness((int)frame->arg1);
case SYS_GETEXECPATH: case SYS_GETEXECPATH:
if (!UserMemory::Range(frame->arg2 ? frame->arg1 : frame->arg1, frame->arg2, true)) return -1; 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); Sys_GetTime((DateTime*)frame->arg1);
return 0; return 0;
case SYS_SETUNIXTIME: 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); return Sys_SetUnixTime((int64_t)frame->arg1);
case SYS_SOCKET: case SYS_SOCKET:
return (int64_t)Sys_Socket((int)frame->arg1); return (int64_t)Sys_Socket((int)frame->arg1);
@@ -274,7 +275,7 @@ namespace montauk::abi {
Sys_GetNetCfg((NetCfg*)frame->arg1); Sys_GetNetCfg((NetCfg*)frame->arg1);
return 0; return 0;
case SYS_SETNETCFG: 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<NetCfg>(frame->arg1)) return -1; if (!UserMemory::Readable<NetCfg>(frame->arg1)) return -1;
return (int64_t)Sys_SetNetCfg((const NetCfg*)frame->arg1); return (int64_t)Sys_SetNetCfg((const NetCfg*)frame->arg1);
case SYS_NETSTATUS: case SYS_NETSTATUS:
@@ -330,7 +331,7 @@ namespace montauk::abi {
if (!UserMemory::Range(frame->arg1, frame->arg2, true)) return -1; if (!UserMemory::Range(frame->arg1, frame->arg2, true)) return -1;
return Sys_GetRandom((uint8_t*)frame->arg1, frame->arg2); return Sys_GetRandom((uint8_t*)frame->arg1, frame->arg2);
case SYS_LOG: 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; if (!UserMemory::Range(frame->arg1, frame->arg2, true)) return -1;
return Kt::ReadKernelLogBuffer((char*)frame->arg1, frame->arg2); return Kt::ReadKernelLogBuffer((char*)frame->arg1, frame->arg2);
case SYS_MOUSESTATE: case SYS_MOUSESTATE:
@@ -438,20 +439,20 @@ namespace montauk::abi {
return (int64_t)Sys_DiskWrite((int)frame->arg1, frame->arg2, return (int64_t)Sys_DiskWrite((int)frame->arg1, frame->arg2,
(uint32_t)frame->arg3, (const void*)frame->arg4); (uint32_t)frame->arg3, (const void*)frame->arg4);
case SYS_GPTINIT: 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); return (int64_t)Sys_GptInit((int)frame->arg1);
case SYS_GPTADD: 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<GptAddParams>(frame->arg1)) return -1; if (!UserMemory::Readable<GptAddParams>(frame->arg1)) return -1;
return (int64_t)Sys_GptAdd((const GptAddParams*)frame->arg1); return (int64_t)Sys_GptAdd((const GptAddParams*)frame->arg1);
case SYS_FSMOUNT: 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); return (int64_t)Sys_FsMount((int)frame->arg1, (int)frame->arg2);
case SYS_FS_SYNC: 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(); return Sys_FsSync();
case SYS_FSFORMAT: 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<FsFormatParams>(frame->arg1)) return -1; if (!UserMemory::Readable<FsFormatParams>(frame->arg1)) return -1;
return (int64_t)Sys_FsFormat((const FsFormatParams*)frame->arg1); return (int64_t)Sys_FsFormat((const FsFormatParams*)frame->arg1);
case SYS_AUDIOOPEN: case SYS_AUDIOOPEN:
@@ -517,6 +518,13 @@ namespace montauk::abi {
case SYS_GETCAPS: case SYS_GETCAPS:
if (!UserMemory::Writable<SpawnCapabilities>(frame->arg1)) return -1; if (!UserMemory::Writable<SpawnCapabilities>(frame->arg1)) return -1;
return Sys_GetCaps((SpawnCapabilities*)frame->arg1); return Sys_GetCaps((SpawnCapabilities*)frame->arg1);
case SYS_CONSENT_REGISTER:
return Sched::Consent::Register();
case SYS_CONSENT_NEXT:
if (!UserMemory::Writable<ConsentRequest>(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: case SYS_THREAD_SPAWN:
return Sys_ThreadSpawn(frame->arg1, frame->arg2, frame->arg3); return Sys_ThreadSpawn(frame->arg1, frame->arg2, frame->arg3);
case SYS_THREAD_EXIT: case SYS_THREAD_EXIT:
@@ -560,7 +568,7 @@ namespace montauk::abi {
if (!UserMemory::Writable<BtAdapterInfo>(frame->arg1)) return -1; if (!UserMemory::Writable<BtAdapterInfo>(frame->arg1)) return -1;
return Sys_BtInfo((BtAdapterInfo*)frame->arg1); return Sys_BtInfo((BtAdapterInfo*)frame->arg1);
case SYS_WIFI_SCAN: 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 ((int64_t)frame->arg2 < 0) return -1;
if (!UserMemory::Range(frame->arg1, (uint64_t)frame->arg2 * sizeof(WifiNetwork), true)) 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); return Sys_WifiScan((WifiNetwork*)frame->arg1, (int)frame->arg2, (uint32_t)frame->arg3);
@@ -568,22 +576,22 @@ namespace montauk::abi {
if (!UserMemory::Writable<WifiInfo>(frame->arg1)) return -1; if (!UserMemory::Writable<WifiInfo>(frame->arg1)) return -1;
return Sys_WifiInfo((WifiInfo*)frame->arg1); return Sys_WifiInfo((WifiInfo*)frame->arg1);
case SYS_WIFI_CONNECT: 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 (!UserMemory::String(frame->arg1, 64)) return -1;
if (frame->arg2 != 0 && !UserMemory::String(frame->arg2, 128)) return -1; if (frame->arg2 != 0 && !UserMemory::String(frame->arg2, 128)) return -1;
return Sys_WifiConnect((const char*)frame->arg1, (const char*)frame->arg2); return Sys_WifiConnect((const char*)frame->arg1, (const char*)frame->arg2);
case SYS_WIFI_DISCONNECT: 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(); return Sys_WifiDisconnect();
case SYS_WIFI_SCAN_START: 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); return Sys_WifiScanStart((uint32_t)frame->arg1);
case SYS_WIFI_RESULTS: case SYS_WIFI_RESULTS:
if ((int64_t)frame->arg2 < 0) return -1; if ((int64_t)frame->arg2 < 0) return -1;
if (!UserMemory::Range(frame->arg1, (uint64_t)frame->arg2 * sizeof(WifiNetwork), true)) 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); return Sys_WifiResults((WifiNetwork*)frame->arg1, (int)frame->arg2);
case SYS_WIFI_CONNECT_ASYNC: 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 (!UserMemory::String(frame->arg1, 64)) return -1;
if (frame->arg2 != 0 && !UserMemory::String(frame->arg2, 128)) return -1; if (frame->arg2 != 0 && !UserMemory::String(frame->arg2, 128)) return -1;
return Sys_WifiConnectAsync((const char*)frame->arg1, (const char*)frame->arg2); 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; if (!Sched::HasCapability(CAP_SUSPEND)) return SYS_ERR_PERMISSION;
return Sys_Suspend(); return Sys_Suspend();
case SYS_SETTZ: 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); return Sys_SetTZ((int32_t)frame->arg1);
case SYS_GETTZ: case SYS_GETTZ:
return Sys_GetTZ(); return Sys_GetTZ();
+44 -9
View File
@@ -352,6 +352,13 @@ namespace montauk::abi {
/* Storage.hpp */ /* Storage.hpp */
static constexpr uint64_t SYS_VOLINFO = 190; // (VolInfo*, maxCount) -> count, -1 bad pointer 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 /* Kernel-owned process capabilities. User identities may namespace
per-user resources, but never participate in authorization decisions. */ per-user resources, but never participate in authorization decisions. */
static constexpr uint64_t CAP_PROCESS_ADMIN = 1ULL << 0; static constexpr uint64_t CAP_PROCESS_ADMIN = 1ULL << 0;
@@ -396,26 +403,55 @@ namespace montauk::abi {
"login grants it to the desktop directly"); "login grants it to the desktop directly");
static constexpr int SYS_ERR_PERMISSION = -13; 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 { struct SpawnCapabilities {
uint64_t permitted;
uint64_t effective; uint64_t effective;
uint64_t delegable; uint64_t delegable;
}; };
// Both of the child's sets must come out of the parent's delegable set.
constexpr bool ValidCapabilityDelegation(const SpawnCapabilities& child, constexpr bool ValidCapabilityDelegation(const SpawnCapabilities& child,
uint64_t parentDelegable) { uint64_t parentDelegable) {
return (child.permitted & ~CAP_ALL) == 0 && return ((child.effective | child.delegable) & ~CAP_ALL) == 0 &&
(child.effective & ~child.permitted) == 0 && (child.effective & ~parentDelegable) == 0 &&
(child.delegable & ~child.permitted) == 0 &&
(child.permitted & ~parentDelegable) == 0 &&
(child.delegable & ~parentDelegable) == 0; (child.delegable & ~parentDelegable) == 0;
} }
static_assert(ValidCapabilityDelegation( 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( static_assert(!ValidCapabilityDelegation(
{CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN}, 0)); {CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN}, 0));
static_assert(!ValidCapabilityDelegation( 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 // Generic USB errors. Claims are restricted to interfaces without a
@@ -741,7 +777,6 @@ namespace montauk::abi {
char name[64]; char name[64];
uint64_t heapUsed; // Distance from UserHeapBase to high-water mark uint64_t heapUsed; // Distance from UserHeapBase to high-water mark
uint64_t cpuTimeMs; // accumulated scheduler runtime uint64_t cpuTimeMs; // accumulated scheduler runtime
uint64_t permittedCaps;
uint64_t effectiveCaps; uint64_t effectiveCaps;
uint64_t delegableCaps; uint64_t delegableCaps;
}; };
+5
View File
@@ -28,6 +28,7 @@ namespace Fs {
{"/config/init.toml", false, montauk::abi::CAP_USER_ADMIN}, {"/config/init.toml", false, montauk::abi::CAP_USER_ADMIN},
{"/config/ssh.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/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}, {"/config/wallpaper.cache", false, montauk::abi::CAP_USER_ADMIN},
// Read by the Bluetooth driver at controller bring-up. // Read by the Bluetooth driver at controller bring-up.
{"/config/bluetooth.toml", false, montauk::abi::CAP_DEVICE_ADMIN}, {"/config/bluetooth.toml", false, montauk::abi::CAP_DEVICE_ADMIN},
@@ -87,6 +88,10 @@ namespace Fs {
return required; return required;
} }
bool IsSystemImage(const char* path) {
return (RequiredFileWriteCapability(path) & montauk::abi::CAP_SYSTEM_IMAGE) != 0;
}
void LogProtectedPaths() { void LogProtectedPaths() {
for (const auto& rule : g_protectedPaths) { for (const auto& rule : g_protectedPaths) {
Kt::KernelLogStream(Kt::INFO, "IPC") << "Protected path " Kt::KernelLogStream(Kt::INFO, "IPC") << "Protected path "
+5
View File
@@ -13,6 +13,11 @@ namespace Fs {
// re-timestamp `path`. Returns 0 when the path is unprotected. // re-timestamp `path`. Returns 0 when the path is unprotected.
uint64_t RequiredFileWriteCapability(const char* path); 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 // Log the rule table at boot, so a path that should be protected and is
// not is visible rather than silently missing. // not is visible rather than silently missing.
void LogProtectedPaths(); void LogProtectedPaths();
+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 <Memory/UserRange.hpp>
#include "Scheduler.hpp" #include "Scheduler.hpp"
#include "ElfLoader.hpp" #include "ElfLoader.hpp"
#include "Consent.hpp"
#include <Fs/ProtectedPaths.hpp>
#include <Memory/PageFrameAllocator.hpp> #include <Memory/PageFrameAllocator.hpp>
#include <Memory/Paging.hpp> #include <Memory/Paging.hpp>
#include <Memory/HHDM.hpp> #include <Memory/HHDM.hpp>
@@ -299,9 +301,10 @@ namespace Sched {
processTable[i].environment[0] = '\0'; processTable[i].environment[0] = '\0';
processTable[i].environmentLength = 1; processTable[i].environmentLength = 1;
processTable[i].user[0] = '\0'; processTable[i].user[0] = '\0';
processTable[i].permittedCaps = 0;
processTable[i].effectiveCaps = 0; processTable[i].effectiveCaps = 0;
processTable[i].delegableCaps = 0; processTable[i].delegableCaps = 0;
processTable[i].consentCeiling = 0;
processTable[i].consentDenied = 0;
processTable[i].sessionId = -1; processTable[i].sessionId = -1;
processTable[i].cwd[0] = '\0'; processTable[i].cwd[0] = '\0';
processTable[i].runningOnCpu = -1; processTable[i].runningOnCpu = -1;
@@ -349,6 +352,20 @@ namespace Sched {
const char* environment, uint32_t environmentLength, const char* environment, uint32_t environmentLength,
const montauk::abi::SpawnCapabilities* capabilities, const montauk::abi::SpawnCapabilities* capabilities,
const char* userOverride) { 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(); schedLock.Acquire();
int slot = -1; int slot = -1;
@@ -558,11 +575,9 @@ namespace Sched {
// must use SYS_SPAWN_CAPS for an explicit, kernel-validated delegation. // must use SYS_SPAWN_CAPS for an explicit, kernel-validated delegation.
if (parentPrimarySlot >= 0) { if (parentPrimarySlot >= 0) {
if (capabilities != nullptr) { if (capabilities != nullptr) {
proc.permittedCaps = capabilities->permitted;
proc.effectiveCaps = capabilities->effective; proc.effectiveCaps = capabilities->effective;
proc.delegableCaps = capabilities->delegable; proc.delegableCaps = capabilities->delegable;
} else { } else {
proc.permittedCaps = 0;
proc.effectiveCaps = 0; proc.effectiveCaps = 0;
proc.delegableCaps = 0; proc.delegableCaps = 0;
} }
@@ -570,10 +585,21 @@ namespace Sched {
// The kernel-created init process is the root of the capability // The kernel-created init process is the root of the capability
// delegation tree. No userspace pathname or PID receives this // delegation tree. No userspace pathname or PID receives this
// treatment; it is reached only with no current parent process. // treatment; it is reached only with no current parent process.
proc.permittedCaps = montauk::abi::CAP_ALL;
proc.effectiveCaps = montauk::abi::CAP_ALL; proc.effectiveCaps = montauk::abi::CAP_ALL;
proc.delegableCaps = 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. // Inherit user string from parent, or default to "system" if no parent.
// An explicit override is accepted only through the validated // An explicit override is accepted only through the validated
@@ -1638,6 +1664,10 @@ namespace Sched {
// Clean up any windows owned by this process // Clean up any windows owned by this process
WinServer::CleanupProcess(exitingPid); 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 // 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. // and its ring buffer don't leak when an app forgets to audio_close.
Drivers::Audio::Mixer::CleanupProcess(exitingPid); Drivers::Audio::Mixer::CleanupProcess(exitingPid);
@@ -2104,6 +2134,73 @@ namespace Sched {
return nullptr; 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) { Process* GetProcessSlot(int slot) {
if (slot < 0 || slot >= MaxProcesses) return nullptr; if (slot < 0 || slot >= MaxProcesses) return nullptr;
return &processTable[slot]; return &processTable[slot];
+18 -1
View File
@@ -91,12 +91,16 @@ namespace Sched {
char environment[EnvironmentBytes]; // NUL-separated NAME=VALUE entries char environment[EnvironmentBytes]; // NUL-separated NAME=VALUE entries
uint32_t environmentLength; uint32_t environmentLength;
char user[32]; // Owner user name (inherited from parent on spawn) 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 effectiveCaps; // Authority currently usable by syscalls
uint64_t delegableCaps; // Authority this process may pass to children uint64_t delegableCaps; // Authority this process may pass to children
int sessionId; // Process-session leader PID (inherited on spawn) int sessionId; // Process-session leader PID (inherited on spawn)
char cwd[256]; // Absolute current working directory 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 // Thread-local storage. fsBase is loaded into IA32_FS_BASE when
// this thread is dispatched (0 = no TLS). The tls* template fields // this thread is dispatched (0 = no TLS). The tls* template fields
// live on the primary slot and describe PT_TLS so sibling threads // 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) // Find a process by PID (returns nullptr if not found or not alive)
Process* GetProcessByPid(int pid); 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`). // Spawn the crashpad to report on a faulted process (pid `crasherPid`).
// Called by the exception handler when a process faults. If the crasher had // 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 // a redirected console, the crashpad inherits its out-stream so it can print
+16 -1
View File
@@ -4,6 +4,17 @@
# Capability names: process_admin, power_request, power_control, suspend, # Capability names: process_admin, power_request, power_control, suspend,
# storage_admin, raw_storage, network_admin, set_time, user_admin, # storage_admin, raw_storage, network_admin, set_time, user_admin,
# display_admin, device_admin, log_read, system_image, compositor and "all". # 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) ==== # ==== System services (started by init) ====
@@ -17,7 +28,6 @@ effective = ["set_time"]
[grant.login] [grant.login]
path = "0:/os/login.elf" path = "0:/os/login.elf"
permitted = ["all", "compositor"]
effective = [ effective = [
"power_control", "storage_admin", "device_admin", "power_control", "storage_admin", "device_admin",
"user_admin", "process_admin", "log_read", "user_admin", "process_admin", "log_read",
@@ -86,6 +96,11 @@ effective = ["set_time"]
path = "0:/apps/bluetooth/bluetooth.elf" path = "0:/apps/bluetooth/bluetooth.elf"
effective = ["device_admin"] 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] [grant.syslog]
path = "0:/apps/syslog/syslog.elf" path = "0:/apps/syslog/syslog.elf"
effective = ["log_read"] effective = ["log_read"]
+44 -9
View File
@@ -265,6 +265,13 @@ namespace montauk::abi {
/* Storage.hpp */ /* Storage.hpp */
static constexpr uint64_t SYS_VOLINFO = 190; // (VolInfo*, maxCount) -> count, -1 bad pointer 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 /* Kernel-owned process capabilities. User identities may namespace
per-user resources, but never participate in authorization decisions. */ per-user resources, but never participate in authorization decisions. */
static constexpr uint64_t CAP_PROCESS_ADMIN = 1ULL << 0; static constexpr uint64_t CAP_PROCESS_ADMIN = 1ULL << 0;
@@ -309,26 +316,55 @@ namespace montauk::abi {
"login grants it to the desktop directly"); "login grants it to the desktop directly");
static constexpr int SYS_ERR_PERMISSION = -13; 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 { struct SpawnCapabilities {
uint64_t permitted;
uint64_t effective; uint64_t effective;
uint64_t delegable; uint64_t delegable;
}; };
// Both of the child's sets must come out of the parent's delegable set.
constexpr bool ValidCapabilityDelegation(const SpawnCapabilities& child, constexpr bool ValidCapabilityDelegation(const SpawnCapabilities& child,
uint64_t parentDelegable) { uint64_t parentDelegable) {
return (child.permitted & ~CAP_ALL) == 0 && return ((child.effective | child.delegable) & ~CAP_ALL) == 0 &&
(child.effective & ~child.permitted) == 0 && (child.effective & ~parentDelegable) == 0 &&
(child.delegable & ~child.permitted) == 0 &&
(child.permitted & ~parentDelegable) == 0 &&
(child.delegable & ~parentDelegable) == 0; (child.delegable & ~parentDelegable) == 0;
} }
static_assert(ValidCapabilityDelegation( 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( static_assert(!ValidCapabilityDelegation(
{CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN}, 0)); {CAP_NETWORK_ADMIN, CAP_NETWORK_ADMIN}, 0));
static_assert(!ValidCapabilityDelegation( 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; static constexpr int USB_ERR_INVALID = -1;
@@ -837,7 +873,6 @@ namespace montauk::abi {
char name[64]; char name[64];
uint64_t heapUsed; // Distance from UserHeapBase to high-water mark uint64_t heapUsed; // Distance from UserHeapBase to high-water mark
uint64_t cpuTimeMs; // accumulated scheduler runtime uint64_t cpuTimeMs; // accumulated scheduler runtime
uint64_t permittedCaps;
uint64_t effectiveCaps; uint64_t effectiveCaps;
uint64_t delegableCaps; uint64_t delegableCaps;
}; };
+3
View File
@@ -214,6 +214,9 @@ extern "C" {
#define MTK_SYS_BATTERYINFO 188 #define MTK_SYS_BATTERYINFO 188
#define MTK_SYS_GETCAPS 189 #define MTK_SYS_GETCAPS 189
#define MTK_SYS_VOLINFO 190 #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 */ /* @SYSCALLS-END */
#define MTK_SOCK_TCP 1 #define MTK_SOCK_TCP 1
+31 -14
View File
@@ -56,6 +56,32 @@ namespace caps {
return 0; 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. // Read an array-of-strings key into a capability mask.
inline uint64_t mask_from_key(const montauk::toml::Doc& doc, const char* key) { inline uint64_t mask_from_key(const montauk::toml::Doc& doc, const char* key) {
montauk::toml::Value* arr = doc.get_array(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". // path has no entry, which is the common case and means "no authority".
inline bool lookup(const char* binary_path, inline bool lookup(const char* binary_path,
montauk::abi::SpawnCapabilities& out) { montauk::abi::SpawnCapabilities& out) {
out = {0, 0, 0}; out = {0, 0};
if (binary_path == nullptr || binary_path[0] == '\0') return false; if (binary_path == nullptr || binary_path[0] == '\0') return false;
montauk::toml::Doc doc = montauk::config::load(GRANT_CONFIG); montauk::toml::Doc doc = montauk::config::load(GRANT_CONFIG);
@@ -117,15 +143,7 @@ namespace caps {
uint64_t effective = mask_from_key(doc, sibling); uint64_t effective = mask_from_key(doc, sibling);
build_sibling_key(entry->key, "delegable", sibling, sizeof(sibling)); build_sibling_key(entry->key, "delegable", sibling, sizeof(sibling));
uint64_t delegable = mask_from_key(doc, 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.effective = effective;
out.delegable = delegable; out.delegable = delegable;
found = true; found = true;
@@ -137,7 +155,7 @@ namespace caps {
// The calling process's own capability masks. // The calling process's own capability masks.
inline bool self(montauk::abi::SpawnCapabilities& out) { inline bool self(montauk::abi::SpawnCapabilities& out) {
out = {0, 0, 0}; out = {0, 0};
return montauk::getcaps(&out) == 0; return montauk::getcaps(&out) == 0;
} }
@@ -150,15 +168,14 @@ namespace caps {
// actually delegate. // actually delegate.
inline montauk::abi::SpawnCapabilities for_binary(const char* binary_path, inline montauk::abi::SpawnCapabilities for_binary(const char* binary_path,
uint64_t caller_delegable) { uint64_t caller_delegable) {
montauk::abi::SpawnCapabilities caps{0, 0, 0}; montauk::abi::SpawnCapabilities caps{0, 0};
if (caller_delegable == 0) return caps; if (caller_delegable == 0) return caps;
if (!lookup(binary_path, caps)) return caps; if (!lookup(binary_path, caps)) return caps;
caps.permitted &= caller_delegable; caps.effective &= caller_delegable;
caps.effective &= caps.permitted; caps.delegable &= caller_delegable;
caps.delegable &= caps.permitted;
return caps; return caps;
} }
+209
View File
@@ -0,0 +1,209 @@
/*
* consent.h
* First-use consent: prompt wording and the stored-decision table
* (0:/config/consent.toml). See docs/consent.md.
* Copyright (c) 2026 Daniel Hammer
*/
#pragma once
#include <montauk/syscall.h>
#include <montauk/string.h>
#include <montauk/toml.h>
#include <montauk/config.h>
#include <montauk/capabilities.h>
namespace montauk {
namespace consent {
inline constexpr const char* STORE_CONFIG = "consent";
inline constexpr const char* STORE_PREFIX = "decision.";
struct CapText {
uint64_t bit;
const char* action; // completes "<App> wants to ..."
const char* icon; // shown in the prompt
};
inline constexpr CapText TEXTS[] = {
{montauk::abi::CAP_STORAGE_ADMIN, "format, partition and mount disks",
"0:/os/icons/drive-harddisk.svg"},
{montauk::abi::CAP_NETWORK_ADMIN, "change network settings and join Wi-Fi networks",
"0:/os/icons/network-wired.svg"},
{montauk::abi::CAP_SET_TIME, "change the system clock and time zone",
"0:/os/icons/preferences-system-time.svg"},
{montauk::abi::CAP_DISPLAY_ADMIN, "change the screen resolution and brightness",
"0:/os/icons/preferences-desktop-display.svg"},
{montauk::abi::CAP_LOG_READ, "read the system log, which can reveal what other apps and devices are doing",
"0:/os/icons/utilities-log-viewer.svg"},
};
// Fallback for a capability without its own icon.
inline constexpr const char* DEFAULT_ICON = "0:/os/icons/lock.svg";
inline const char* action_for(uint64_t bit) {
for (const auto& t : TEXTS) {
if (t.bit == bit) return t.action;
}
return "use a protected system capability";
}
inline const char* icon_for(uint64_t bit) {
for (const auto& t : TEXTS) {
if (t.bit == bit) return t.icon;
}
return DEFAULT_ICON;
}
inline const char* name_for(uint64_t bit) {
for (const auto& n : montauk::caps::NAMES) {
if (n.bit == bit) return n.name;
}
return "";
}
// One stored decision, as read from the table. Only programs installed
// under 0:/os or 0:/apps are ever stored: those paths cannot be rewritten
// without CAP_SYSTEM_IMAGE, so the path alone identifies the program.
// The kernel reports this as ConsentRequest::imageProtected.
struct Decision {
char id[16];
char path[256];
uint64_t capability;
bool allow;
};
namespace detail {
inline void key(char* out, int cap, const char* id, const char* field) {
int n = 0;
for (const char* p = STORE_PREFIX; *p && n < cap - 1; p++) out[n++] = *p;
for (const char* p = id; *p && n < cap - 1; p++) out[n++] = *p;
if (n < cap - 1) out[n++] = '.';
for (const char* p = field; *p && n < cap - 1; p++) out[n++] = *p;
out[n] = '\0';
}
// Ids of "decision.<id>.path" entries, in file order.
inline int collect_ids(const montauk::toml::Doc& doc, char (*ids)[16], int max) {
int count = 0;
int prefixLen = montauk::slen(STORE_PREFIX);
for (int i = 0; i < doc.entries.count && count < max; i++) {
montauk::toml::Value* v = doc.entries.items[i];
if (v == nullptr || v->key == nullptr) continue;
if (!montauk::starts_with(v->key, STORE_PREFIX)) continue;
const char* rest = v->key + prefixLen;
int dot = 0;
while (rest[dot] && rest[dot] != '.') dot++;
if (rest[dot] != '.' || !montauk::streq(rest + dot + 1, "path")) continue;
if (dot <= 0 || dot >= 16) continue;
montauk::memcpy(ids[count], rest, dot);
ids[count][dot] = '\0';
count++;
}
return count;
}
inline bool read(const montauk::toml::Doc& doc, const char* id, Decision& out) {
char k[64];
montauk::memset(&out, 0, sizeof(out));
montauk::strncpy(out.id, id, sizeof(out.id));
key(k, sizeof(k), id, "path");
montauk::strncpy(out.path, doc.get_string(k, ""), sizeof(out.path));
key(k, sizeof(k), id, "capability");
out.capability = montauk::caps::bit_for_name(doc.get_string(k, ""));
key(k, sizeof(k), id, "decision");
out.allow = montauk::streq(doc.get_string(k, ""), "allow");
// "all" or an unknown name is never a valid stored decision.
return out.path[0] &&
(out.capability & montauk::abi::CAP_CONSENT_ELIGIBLE) != 0 &&
(out.capability & (out.capability - 1)) == 0;
}
inline void remove_id(montauk::toml::Doc* doc, const char* id) {
static const char* fields[] = {"path", "capability", "decision"};
char k[64];
for (const char* f : fields) {
key(k, sizeof(k), id, f);
montauk::config::unset(doc, k);
}
}
} // namespace detail
// Read up to `max` stored decisions. Returns the count.
inline int load_all(Decision* out, int max) {
montauk::toml::Doc doc = montauk::config::load(STORE_CONFIG);
char ids[128][16];
int idCount = detail::collect_ids(doc, ids, 128);
int count = 0;
for (int i = 0; i < idCount && count < max; i++) {
if (detail::read(doc, ids[i], out[count])) count++;
}
doc.destroy();
return count;
}
// Stored answer for (path, capability): 1 allow, 0 deny, -1 none.
inline int lookup(const char* path, uint64_t capability) {
montauk::toml::Doc doc = montauk::config::load(STORE_CONFIG);
char ids[128][16];
int idCount = detail::collect_ids(doc, ids, 128);
int result = -1;
for (int i = 0; i < idCount && result < 0; i++) {
Decision d;
if (!detail::read(doc, ids[i], d)) continue;
if (d.capability == capability && montauk::streq(d.path, path)) {
result = d.allow ? 1 : 0;
}
}
doc.destroy();
return result;
}
// Store an answer, replacing any earlier one for the same path and
// capability. Returns 0 on success, negative when the table cannot be
// written.
inline int record(const char* path, uint64_t capability, bool allow) {
montauk::toml::Doc doc = montauk::config::load(STORE_CONFIG);
char ids[128][16];
int idCount = detail::collect_ids(doc, ids, 128);
int nextId = 1;
for (int i = 0; i < idCount; i++) {
Decision d;
bool valid = detail::read(doc, ids[i], d);
if (valid && d.capability == capability && montauk::streq(d.path, path))
detail::remove_id(&doc, ids[i]);
int n = 0;
for (const char* p = ids[i]; *p >= '0' && *p <= '9'; p++) n = n * 10 + (*p - '0');
if (n >= nextId) nextId = n + 1;
}
char id[16];
int len = 0;
char rev[16];
for (int n = nextId; n > 0 && len < 15; n /= 10) rev[len++] = (char)('0' + n % 10);
for (int i = 0; i < len; i++) id[i] = rev[len - 1 - i];
id[len] = '\0';
char k[64];
detail::key(k, sizeof(k), id, "path");
montauk::config::set_string(&doc, k, path);
detail::key(k, sizeof(k), id, "capability");
montauk::config::set_string(&doc, k, name_for(capability));
detail::key(k, sizeof(k), id, "decision");
montauk::config::set_string(&doc, k, allow ? "allow" : "deny");
int rc = montauk::config::save(STORE_CONFIG, &doc);
doc.destroy();
return rc;
}
// Drop one stored decision by id; the program is asked again next time.
inline int forget(const char* id) {
montauk::toml::Doc doc = montauk::config::load(STORE_CONFIG);
detail::remove_id(&doc, id);
int rc = montauk::config::save(STORE_CONFIG, &doc);
doc.destroy();
return rc;
}
} // namespace consent
} // namespace montauk
+11
View File
@@ -131,6 +131,17 @@ namespace montauk {
inline int getcaps(montauk::abi::SpawnCapabilities* out) { inline int getcaps(montauk::abi::SpawnCapabilities* out) {
return (int)syscall1(montauk::abi::SYS_GETCAPS, (uint64_t)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) { inline int chdir(const char* path) {
return (int)syscall1(montauk::abi::SYS_CHDIR, (uint64_t)path); return (int)syscall1(montauk::abi::SYS_CHDIR, (uint64_t)path);
} }
+137 -26
View File
@@ -8,6 +8,7 @@
#include <montauk/string.h> #include <montauk/string.h>
#include <montauk/config.h> #include <montauk/config.h>
#include <montauk/capabilities.h> #include <montauk/capabilities.h>
#include <montauk/consent.h>
#include <gui/mtk.hpp> #include <gui/mtk.hpp>
#include <gui/mtk/settings.hpp> #include <gui/mtk/settings.hpp>
#include <gui/standalone.hpp> #include <gui/standalone.hpp>
@@ -30,6 +31,7 @@ static constexpr int GRID_ROW_H = 22;
static constexpr int GRID_COL_W = 86; static constexpr int GRID_COL_W = 86;
static constexpr int MAX_GRANTS = 64; static constexpr int MAX_GRANTS = 64;
static constexpr int MAX_ISSUES = 4; static constexpr int MAX_ISSUES = 4;
static constexpr int MAX_DECISIONS = 64;
static constexpr int CAP_COUNT = static constexpr int CAP_COUNT =
(int)(sizeof(montauk::caps::NAMES) / sizeof(montauk::caps::NAMES[0])); (int)(sizeof(montauk::caps::NAMES) / sizeof(montauk::caps::NAMES[0]));
@@ -37,12 +39,14 @@ static constexpr int CAP_COUNT =
enum Tab { enum Tab {
TAB_PROGRAMS = 0, TAB_PROGRAMS = 0,
TAB_CAPS = 1, TAB_CAPS = 1,
TAB_COUNT = 2, TAB_CONSENT = 2,
TAB_COUNT = 3,
}; };
static const char* const kTabLabels[TAB_COUNT] = { static const char* const kTabLabels[TAB_COUNT] = {
"Programs", "Programs",
"Capabilities", "Capabilities",
"Consent",
}; };
// What each capability gates, in the order of montauk::caps::NAMES. // What each capability gates, in the order of montauk::caps::NAMES.
@@ -68,16 +72,15 @@ static const char* const kCapDescriptions[CAP_COUNT] = {
enum Field { enum Field {
FIELD_EFFECTIVE = 0, FIELD_EFFECTIVE = 0,
FIELD_DELEGABLE = 1, FIELD_DELEGABLE = 1,
FIELD_PERMITTED = 2, FIELD_COUNT = 2,
FIELD_COUNT = 3,
}; };
static const char* const kFieldKeys[FIELD_COUNT] = { static const char* const kFieldKeys[FIELD_COUNT] = {
"effective", "delegable", "permitted", "effective", "delegable",
}; };
static const char* const kFieldLabels[FIELD_COUNT] = { static const char* const kFieldLabels[FIELD_COUNT] = {
"Effective", "Delegable", "Permitted", "Effective", "Delegable",
}; };
struct Grant { struct Grant {
@@ -104,6 +107,11 @@ static Color g_accent = colors::ACCENT;
static bool g_is_admin = false; static bool g_is_admin = false;
static int g_selected_grant = 0; static int g_selected_grant = 0;
static int g_selected_cap = 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 int g_list_scroll = 0;
static bool g_list_sb_dragging = false; static bool g_list_sb_dragging = false;
static int g_list_sb_drag_offset = 0; 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++) { for (int i = 0; i < g_grant_count; i++) {
Grant& g = g_grants[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') { if (g.path[0] == '\0') {
add_issue(g, "No path; this grant never applies"); add_issue(g, "No path; this grant never applies");
@@ -231,6 +231,8 @@ static void load_grants() {
g.binary_present = bh >= 0; g.binary_present = bh >= 0;
if (bh >= 0) montauk::close(bh); if (bh >= 0) montauk::close(bh);
if (!g.binary_present) add_issue(g, "Binary not found on this system"); 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++) { for (int j = 0; j < i; j++) {
if (montauk::streq(g_grants[j].path, g.path)) { 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; 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 ==== // ==== Layout ====
static mtk::Theme app_theme() { static mtk::Theme app_theme() {
@@ -271,7 +292,15 @@ static Rect list_rect() {
} }
static int list_count() { 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() { 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}; 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) { static bool mouse_in(const Rect& rect) {
return rect.contains(g_mouse_x, g_mouse_y); 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; int index = g_list_scroll + row;
if (index >= count) break; if (index >= count) break;
Rect r = list_row_rect(row); 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); mtk::draw_list_row(c, r, selected, false, theme);
if (!selected && mouse_in(r)) if (!selected && mouse_in(r))
c.fill_rounded_rect(r.x, r.y, r.w, r.h, theme.radius_md, theme.surface_hover); 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 const char* label = g_tab == TAB_PROGRAMS ? g_grants[index].id
? g_grants[index].id : montauk::caps::NAMES[index].name; : g_tab == TAB_CONSENT ? base_name(g_decisions[index].path)
: montauk::caps::NAMES[index].name;
Color fg = selected ? theme.accent_fg : theme.text; Color fg = selected ? theme.accent_fg : theme.text;
int ty = r.y + (r.h - fh) / 2; int ty = r.y + (r.h - fh) / 2;
draw_text_fit(c, r.x + 10, ty, label, r.w - 34, fg); 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) { if (g_tab == TAB_PROGRAMS && g_grants[index].issue_count > 0) {
Color dot = selected ? theme.accent_fg : theme.danger; Color dot = selected ? theme.accent_fg : theme.danger;
fill_circle(c, r.x + r.w - 14, r.y + r.h / 2, 4, dot); 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++) { for (int f = 0; f < FIELD_COUNT; f++) {
const char* note = nullptr; const char* note = nullptr;
if (g.uses_all[f]) note = "(all)"; if (g.uses_all[f]) note = "(all)";
else if (f == FIELD_PERMITTED && !g.declared[f]) note = "(implied)";
if (!note) continue; if (!note) continue;
int cx = colX + f * GRID_COL_W + GRID_COL_W / 2; int cx = colX + f * GRID_COL_W + GRID_COL_W / 2;
c.text(cx - text_width(note) / 2, y, note, theme.text_muted); 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++) { for (int i = 0; i < CAP_COUNT; i++) {
uint64_t bit = montauk::caps::NAMES[i].bit; 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) if (i % 2 == 1)
c.fill_rect(x, y, w, GRID_ROW_H, theme.surface_alt); 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, 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); "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) { static void draw_locked(Canvas& c, const mtk::Theme& theme) {
int fh = system_font_height(); int fh = system_font_height();
int y = TAB_H + 60; int y = TAB_H + 60;
@@ -564,6 +645,10 @@ static void draw_footer(Canvas& c, const mtk::Theme& theme) {
int flagged = 0; int flagged = 0;
for (int i = 0; i < g_grant_count; i++) for (int i = 0; i < g_grant_count; i++)
if (g_grants[i].issue_count > 0) flagged++; if (g_grants[i].issue_count > 0) 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", snprintf(summary, sizeof(summary), "%d grant%s%s, %d flagged - read-only view",
g_grant_count, g_grant_count == 1 ? "" : "s", g_grant_count, g_grant_count == 1 ? "" : "s",
g_truncated ? " (list truncated)" : "", flagged); g_truncated ? " (list truncated)" : "", flagged);
@@ -575,6 +660,11 @@ static void draw_footer(Canvas& c, const mtk::Theme& theme) {
mtk::draw_button(c, reload, "Reload", mtk::BUTTON_SECONDARY, mtk::draw_button(c, reload, "Reload", mtk::BUTTON_SECONDARY,
mtk::widget_state(false, mouse_in(reload), true), theme); 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() { 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); mtk::draw_tab_bar(c, tab_bar_rect(), kTabLabels, TAB_COUNT, (int)g_tab, theme);
draw_list(c, theme); draw_list(c, theme);
if (g_tab == TAB_PROGRAMS) draw_programs_pane(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_caps_pane(c, theme);
} else { } else {
draw_locked(c, theme); draw_locked(c, theme);
@@ -598,8 +689,25 @@ static void render() {
static void reload() { static void reload() {
load_grants(); load_grants();
load_decisions();
clamp_list_scroll(); 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) { static void select_index(int index) {
@@ -607,8 +715,7 @@ static void select_index(int index) {
if (count <= 0) return; if (count <= 0) return;
if (index < 0) index = 0; if (index < 0) index = 0;
if (index >= count) index = count - 1; if (index >= count) index = count - 1;
if (g_tab == TAB_PROGRAMS) g_selected_grant = index; *selected_for_tab() = index;
else g_selected_cap = index;
int visible = list_visible_rows(); int visible = list_visible_rows();
if (index < g_list_scroll) g_list_scroll = index; 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) { static void switch_tab(Tab tab) {
g_tab = tab; g_tab = tab;
g_list_scroll = 0; 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) { 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 (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) { 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 (!g_is_admin) return false;
if (key.ascii == '\t') { if (key.ascii == '\t') {
switch_tab(g_tab == TAB_PROGRAMS ? TAB_CAPS : TAB_PROGRAMS); switch_tab((Tab)((g_tab + 1) % TAB_COUNT));
return true; 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 == 0x48) { select_index(current - 1); return true; } // Up
if (key.scancode == 0x50) { select_index(current + 1); return true; } // Down if (key.scancode == 0x50) { select_index(current + 1); return true; } // Down
if (key.ascii == 'r' || key.ascii == 'R') { if (key.ascii == 'r' || key.ascii == 'R') {
@@ -703,7 +811,10 @@ extern "C" void _start() {
g_accent = mtk::load_system_accent(); g_accent = mtk::load_system_accent();
g_is_admin = session_is_admin(); 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); if (!g_win.create("Capabilities", WIN_W, WIN_H)) montauk::exit(1);
render(); render();
+1 -1
View File
@@ -56,7 +56,7 @@ LDFLAGS := \
# ---- C++ source files ---- # ---- 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)) APP_SRCS := $(sort $(shell find apps -name '*.cpp' -print))
SRCS := $(CORE_SRCS) $(APP_SRCS) SRCS := $(CORE_SRCS) $(APP_SRCS)
OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o))
+449
View File
@@ -0,0 +1,449 @@
/*
* consent.cpp
* Consent agent: answers the kernel's first-use capability requests from
* stored decisions, or asks the user in a prompt window (docs/consent.md)
* Copyright (c) 2026 Daniel Hammer
*/
#include "desktop_internal.hpp"
#include <gui/mtk.hpp>
#include <montauk/consent.h>
// The prompt is an ordinary window inside the desktop, but an INTERNAL one:
// its pixels live in the desktop's heap, not in a window-server surface, so
// no app can map it (SYS_WINMAP) and only real input routed by the desktop
// reaches it. What remains is clickjacking -- an app raising a window over
// the prompt, or stealing focus, just as the user clicks. So Allow only
// arms after the prompt has been focused and on top for ARM_DELAY_MS without
// interruption; losing either restarts the delay. Don't Allow and closing
// are always safe and never wait.
namespace {
constexpr int PROMPT_W = 460;
constexpr int PAD = 20;
constexpr int ICON = 32;
constexpr int BTN_W = 120;
constexpr int BTN_GAP = 12;
constexpr int MAX_LINES = 6;
constexpr int LINE_CHARS = 160;
constexpr uint64_t ARM_DELAY_MS = 700;
struct ConsentPrompt {
DesktopState* ds;
montauk::abi::ConsentRequest req;
char headline[MAX_LINES][LINE_CHARS];
int headline_lines;
char path[2][LINE_CHARS];
int path_lines;
uint64_t opened_at;
uint64_t armed_since; // 0 while not both focused and on top
bool armed;
bool answered;
bool hover_allow;
bool hover_deny;
};
bool g_registered = false;
ConsentPrompt* g_open = nullptr; // one prompt at a time; the rest wait in the kernel
Color g_icon_fill = {};
// Prompt icons, loaded on first use: one per capability plus the fallback.
struct CachedIcon {
const char* path;
SvgIcon icon;
};
CachedIcon g_icons[8] = {};
const SvgIcon* icon_at(const char* path) {
for (auto& c : g_icons) {
if (c.path && montauk::streq(c.path, path)) return c.icon.pixels ? &c.icon : nullptr;
if (c.path == nullptr) {
c.path = path;
c.icon = svg_load(path, ICON, ICON, g_icon_fill);
return c.icon.pixels ? &c.icon : nullptr;
}
}
return nullptr;
}
const SvgIcon* icon_for(uint64_t capability) {
const SvgIcon* icon = icon_at(montauk::consent::icon_for(capability));
return icon ? icon : icon_at(montauk::consent::DEFAULT_ICON);
}
// ==== Text ====
// Greedy word wrap into fixed line buffers. A word wider than the line is
// hard-broken so hostile input cannot overflow the layout.
int wrap_text(const char* text, int max_w, char (*lines)[LINE_CHARS], int max_lines) {
int count = 0;
const char* p = text;
while (*p && count < max_lines) {
while (*p == ' ') p++;
if (!*p) break;
char* line = lines[count];
int len = 0;
int last_break = -1;
const char* resume = p;
while (*p && len < LINE_CHARS - 1) {
line[len] = *p;
line[len + 1] = '\0';
if (text_width(line) > max_w && len > 0) {
line[len] = '\0';
break;
}
if (*p == ' ') {
last_break = len;
resume = p + 1;
}
len++;
p++;
}
line[len] = '\0';
if (*p && *p != ' ' && last_break > 0) {
// Back up to the last space so a word is not split.
line[last_break] = '\0';
p = resume;
}
count++;
}
if (*p && count == max_lines) {
// Out of lines: mark the cut rather than hide it.
char* last = lines[max_lines - 1];
int len = montauk::slen(last);
if (len > LINE_CHARS - 4) len = LINE_CHARS - 4;
while (len > 0 && text_width(last) + text_width("...") > max_w) last[--len] = '\0';
last[len] = '.'; last[len + 1] = '.'; last[len + 2] = '.'; last[len + 3] = '\0';
}
return count;
}
// The file name is chosen by whoever put the binary there. Show it as
// printable ASCII so control bytes cannot fake the prompt's layout.
void make_printable(char* s) {
for (; *s; s++) {
if (*s < 0x20 || *s >= 0x7F) *s = '?';
}
}
// "0:/apps/foo/foo.elf" -> "foo" when no installed app claims the path.
void app_name_for(const char* path, char* out, int cap) {
for (int i = 0; i < menu_row_count; i++) {
const MenuRow& row = menu_rows[i];
if (row.external && montauk::streq(row.binary_path, path)) {
montauk::strncpy(out, row.label, cap);
return;
}
}
const char* base = path;
for (const char* p = path; *p; p++) {
if (*p == '/' || *p == ':') base = p + 1;
}
montauk::strncpy(out, base, cap);
int len = montauk::slen(out);
if (len > 4 && montauk::streq(out + len - 4, ".elf")) out[len - 4] = '\0';
}
// ==== Layout ====
gui::mtk::Theme prompt_theme(const DesktopState* ds) {
return gui::mtk::make_theme(ds->settings.accent_color);
}
int text_w() {
return PROMPT_W - 2 * BORDER_WIDTH - PAD * 2 - ICON - 14;
}
int line_h() {
return system_font_height() + 5;
}
Rect allow_rect(int cw, int ch, const gui::mtk::Theme& theme) {
return {cw - PAD - BTN_W * 2 - BTN_GAP, ch - PAD - theme.control_h, BTN_W, theme.control_h};
}
Rect deny_rect(int cw, int ch, const gui::mtk::Theme& theme) {
return {cw - PAD - BTN_W, ch - PAD - theme.control_h, BTN_W, theme.control_h};
}
int content_height(const ConsentPrompt* cp, const gui::mtk::Theme& theme) {
int h = PAD;
h += cp->headline_lines * line_h() + 10;
h += cp->path_lines * line_h() + 12;
h += line_h() * 2 + 8; // trust advice
if (!cp->req.imageProtected) h += line_h() * 2 + 8;
h += theme.control_h + PAD;
return h;
}
// ==== Answering ====
ConsentPrompt* prompt_of(Window* win) {
return win ? (ConsentPrompt*)win->app_data : nullptr;
}
// `remember` is the user's intent; it only takes effect for an installed
// program, since any other binary can be swapped at the same path.
void answer(ConsentPrompt* cp, bool allow, bool remember) {
if (cp->answered) return;
cp->answered = true;
if (remember && cp->req.imageProtected &&
montauk::consent::record(cp->req.imagePath, cp->req.capability, allow) < 0) {
montauk::print("[desktop] consent: could not save the decision to 0:/config/consent.toml\n");
}
montauk::consent_reply(cp->req.id, allow ? montauk::abi::CONSENT_ALLOW
: montauk::abi::CONSENT_DENY);
}
void close_prompt(ConsentPrompt* cp) {
DesktopState* ds = cp->ds;
for (int i = 0; i < ds->window_count; i++) {
if (ds->windows[i].app_data == cp) {
desktop_close_window(ds, i);
return;
}
}
}
bool prompt_is_front(const DesktopState* ds, const Window* win) {
return !ds->screen_locked && ds->window_count > 0 &&
win == &ds->windows[ds->window_count - 1] && win->focused &&
win->state != WIN_MINIMIZED && win->state != WIN_CLOSED;
}
// ==== Window callbacks ====
void prompt_on_draw(Window* win, Framebuffer& fb) {
(void)fb;
ConsentPrompt* cp = prompt_of(win);
if (!cp) return;
gui::mtk::Theme theme = prompt_theme(cp->ds);
Canvas c(win);
c.fill(theme.window_bg);
if (const SvgIcon* icon = icon_for(cp->req.capability)) c.icon(PAD, PAD, *icon);
int x = PAD + ICON + 14;
int y = PAD;
int lh = line_h();
for (int i = 0; i < cp->headline_lines; i++, y += lh)
c.text(x, y, cp->headline[i], theme.text);
y += 10;
for (int i = 0; i < cp->path_lines; i++, y += lh)
c.text(x, y, cp->path[i], theme.text_subtle);
y += 12;
c.text(x, y, "Only allow this if you trust the app and", theme.text_muted);
c.text(x, y + lh, "expected it to ask.", theme.text_muted);
if (!cp->req.imageProtected) {
y += lh * 2 + 8;
c.text(x, y, "This app is not installed in 0:/apps, so the", theme.text_muted);
c.text(x, y + lh, "answer applies only until it exits.", theme.text_muted);
}
gui::mtk::draw_button(c, allow_rect(c.w, c.h, theme), "Allow",
gui::mtk::BUTTON_SECONDARY,
gui::mtk::widget_state(false, cp->hover_allow && cp->armed, cp->armed),
theme);
gui::mtk::draw_button(c, deny_rect(c.w, c.h, theme), "Don't Allow",
gui::mtk::BUTTON_PRIMARY,
gui::mtk::widget_state(false, cp->hover_deny, true),
theme);
}
void prompt_on_poll(Window* win) {
ConsentPrompt* cp = prompt_of(win);
if (!cp || cp->answered) return;
uint64_t now = montauk::get_milliseconds();
// The kernel has already denied it; an answer now would be refused.
if (now - cp->opened_at >= montauk::abi::CONSENT_TIMEOUT_MS) {
cp->answered = true;
close_prompt(cp);
return;
}
bool front = prompt_is_front(cp->ds, win);
if (!front) {
cp->armed_since = 0;
} else if (cp->armed_since == 0) {
cp->armed_since = now;
}
bool armed = front && now - cp->armed_since >= ARM_DELAY_MS;
if (armed != cp->armed) {
cp->armed = armed;
win->dirty = true;
}
}
void prompt_on_mouse(Window* win, MouseEvent& ev) {
ConsentPrompt* cp = prompt_of(win);
if (!cp || cp->answered) return;
gui::mtk::Theme theme = prompt_theme(cp->ds);
gui::mtk::DesktopHost host(win);
int mx = 0;
int my = 0;
if (!host.map_mouse(ev, &mx, &my)) return;
cp->hover_allow = allow_rect(win->content_w, win->content_h, theme).contains(mx, my);
cp->hover_deny = deny_rect(win->content_w, win->content_h, theme).contains(mx, my);
if (!ev.left_pressed()) return;
if (cp->hover_deny) {
answer(cp, false, true);
close_prompt(cp);
} else if (cp->hover_allow && cp->armed && prompt_is_front(cp->ds, win)) {
answer(cp, true, true);
close_prompt(cp);
}
}
void prompt_on_key(Window* win, const montauk::abi::KeyEvent& key) {
ConsentPrompt* cp = prompt_of(win);
if (!cp || cp->answered || !key.pressed) return;
if (key.scancode == 0x01) { // Escape: dismiss, ask again later
answer(cp, false, false);
close_prompt(cp);
} else if ((key.ascii == '\n' || key.ascii == '\r') && cp->armed) {
// Return picks the default, Don't Allow. Armed so a key held while
// the prompt appeared does not answer it.
answer(cp, false, true);
close_prompt(cp);
}
}
void prompt_on_close(Window* win) {
ConsentPrompt* cp = prompt_of(win);
if (!cp) return;
// Closed without an answer (title-bar close, session ending): deny this
// time only.
answer(cp, false, false);
if (g_open == cp) g_open = nullptr;
montauk::mfree(cp);
win->app_data = nullptr;
}
// ==== Opening ====
// Centre over the requesting app's window when it has one, so the prompt is
// visibly about that app; otherwise centre on the screen.
void place_prompt(DesktopState* ds, int pid, int w, int h, int* out_x, int* out_y) {
int cx = ds->screen_w / 2;
int cy = ds->screen_h / 2;
montauk::abi::WinInfo infos[MAX_WINDOWS];
int count = montauk::win_enumerate(infos, MAX_WINDOWS);
for (int e = 0; e < count; e++) {
if (infos[e].ownerPid != pid) continue;
for (int i = 0; i < ds->window_count; i++) {
const Window& win = ds->windows[i];
if (!win.external || win.ext_win_id != infos[e].id) continue;
if (win.state == WIN_MINIMIZED || win.state == WIN_CLOSED) continue;
cx = win.frame.x + win.frame.w / 2;
cy = win.frame.y + win.frame.h / 2;
e = count;
break;
}
}
int x = cx - w / 2;
int y = cy - h / 2;
if (x + w > ds->screen_w) x = ds->screen_w - w;
if (y + h > ds->screen_h) y = ds->screen_h - h;
if (x < 0) x = 0;
if (y < PANEL_HEIGHT) y = PANEL_HEIGHT;
*out_x = x;
*out_y = y;
}
bool open_prompt(DesktopState* ds, const montauk::abi::ConsentRequest& req) {
ConsentPrompt* cp = (ConsentPrompt*)montauk::malloc(sizeof(ConsentPrompt));
if (!cp) return false;
montauk::memset(cp, 0, sizeof(*cp));
cp->ds = ds;
cp->req = req;
cp->opened_at = montauk::get_milliseconds();
char name[48];
app_name_for(req.imagePath, name, sizeof(name));
make_printable(name);
char shown_path[sizeof(req.imagePath)];
montauk::strncpy(shown_path, req.imagePath, sizeof(shown_path));
make_printable(shown_path);
char headline[320];
snprintf(headline, sizeof(headline), "\"%s\" wants to %s.", name,
montauk::consent::action_for(req.capability));
cp->headline_lines = wrap_text(headline, text_w(), cp->headline, MAX_LINES);
// The path is where the user can tell a system app from a download.
cp->path_lines = wrap_text(shown_path, text_w(), cp->path, 2);
gui::mtk::Theme theme = prompt_theme(ds);
int w = PROMPT_W;
int h = content_height(cp, theme) + TITLEBAR_HEIGHT + BORDER_WIDTH;
int x = 0;
int y = 0;
place_prompt(ds, req.pid, w, h, &x, &y);
int idx = desktop_create_window(ds, "Permission Request", x, y, w, h);
if (idx < 0) {
montauk::mfree(cp);
return false;
}
Window* win = &ds->windows[idx];
win->app_data = cp;
win->on_draw = prompt_on_draw;
win->on_mouse = prompt_on_mouse;
win->on_key = prompt_on_key;
win->on_poll = prompt_on_poll;
win->on_close = prompt_on_close;
g_open = cp;
return true;
}
} // namespace
// ==== Agent ====
void desktop_consent_init(DesktopState* ds) {
// Every session registers; a standard session simply never receives a
// request, because it has none of the eligible capabilities to delegate.
g_registered = montauk::consent_register() == 0;
if (!g_registered) {
montauk::print("[desktop] consent: could not register as the consent agent\n");
return;
}
g_icon_fill = gui::mtk::make_theme(ds->settings.accent_color).text;
}
bool desktop_consent_poll(DesktopState* ds) {
if (!g_registered || g_open != nullptr) return false;
// Drain whatever the stored decisions settle; stop at the first request
// that needs the user.
for (;;) {
montauk::abi::ConsentRequest req;
if (montauk::consent_next(&req) != 1) return false;
int stored = req.imageProtected
? montauk::consent::lookup(req.imagePath, req.capability) : -1;
if (stored >= 0) {
montauk::consent_reply(req.id, stored ? montauk::abi::CONSENT_ALLOW
: montauk::abi::CONSENT_DENY);
continue;
}
if (!open_prompt(ds, req)) {
// Could not show it, so the user cannot have agreed.
montauk::consent_reply(req.id, montauk::abi::CONSENT_DENY);
continue;
}
return true;
}
}
@@ -181,6 +181,10 @@ inline gui::Rect desktop_content_rect(const gui::Window* win) {
gui::ResizeEdge hit_test_resize_edge(const gui::Rect& f, int mx, int my); gui::ResizeEdge hit_test_resize_edge(const gui::Rect& f, int mx, int my);
gui::CursorStyle cursor_for_edge(gui::ResizeEdge edge); 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 // panel.cpp
void desktop_draw_app_menu(gui::DesktopState* ds); void desktop_draw_app_menu(gui::DesktopState* ds);
void desktop_draw_net_popup(gui::DesktopState* ds); void desktop_draw_net_popup(gui::DesktopState* ds);
+4
View File
@@ -393,6 +393,7 @@ void gui::desktop_init(DesktopState* ds) {
desktop_refresh_netifs(ds); desktop_refresh_netifs(ds);
desktop_wifi_init(ds); desktop_wifi_init(ds);
desktop_battery_init(ds); desktop_battery_init(ds);
desktop_consent_init(ds);
ds->vol_popup_open = false; ds->vol_popup_open = false;
ds->vol_icon_rect = {0, 0, 0, 0}; 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) // Poll external windows (discover new, remove dead, update dirty)
sceneChanged |= desktop_poll_external_windows(ds); 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) { if (!ds->screen_locked) {
// Poll windows that have a poll callback // Poll windows that have a poll callback
for (int i = 0; i < ds->window_count; i++) { for (int i = 0; i < ds->window_count; i++) {
+2 -4
View File
@@ -59,11 +59,10 @@ void launch_session(LoginState* ls) {
// session is delegable for the same reason an admin one is -- otherwise // 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. // its own terminal could not hand the shell the suspend it already has.
montauk::abi::SpawnCapabilities caps{}; 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_ADMIN_SESSION
: montauk::abi::CAP_STANDARD_SESSION; : montauk::abi::CAP_STANDARD_SESSION;
caps.effective = caps.permitted; caps.delegable = caps.effective;
caps.delegable = caps.permitted;
int pid; int pid;
if (ls->session_mode == SESSION_CONSOLE) { if (ls->session_mode == SESSION_CONSOLE) {
@@ -74,7 +73,6 @@ void launch_session(LoginState* ls) {
} else { } else {
// The desktop is also the compositor. That authority is not the // The desktop is also the compositor. That authority is not the
// session's to pass on, so it is added here and not delegable. // 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; caps.effective |= montauk::abi::CAP_COMPOSITOR;
pid = montauk::spawn_with_caps("0:/os/desktop.elf", ls->username, pid = montauk::spawn_with_caps("0:/os/desktop.elf", ls->username,
ls->username, caps); ls->username, caps);
+2 -3
View File
@@ -27,14 +27,13 @@ static void maybe_run_setup_session() {
montauk::abi::SpawnCapabilities caps{}; montauk::abi::SpawnCapabilities caps{};
bool admin = montauk::streq(role, "admin"); bool admin = montauk::streq(role, "admin");
caps.permitted = caps.effective = admin caps.effective = admin
? montauk::abi::CAP_ADMIN_SESSION ? montauk::abi::CAP_ADMIN_SESSION
: montauk::abi::CAP_STANDARD_SESSION; : montauk::abi::CAP_STANDARD_SESSION;
// Matches launch_session(): a session delegates from what it holds, and // Matches launch_session(): a session delegates from what it holds, and
// the grant table decides which program receives which part of it. // 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. // Compositor authority for the desktop itself, never delegable onwards.
caps.permitted |= montauk::abi::CAP_COMPOSITOR;
caps.effective |= montauk::abi::CAP_COMPOSITOR; caps.effective |= montauk::abi::CAP_COMPOSITOR;
int pid = montauk::spawn_with_caps("0:/os/desktop.elf", user, user, caps); int pid = montauk::spawn_with_caps("0:/os/desktop.elf", user, user, caps);
if (pid >= 0) { if (pid >= 0) {
+4
View File
@@ -155,6 +155,10 @@ ICONS=(
"status/symbolic/audio-volume-high-symbolic.svg" "status/symbolic/audio-volume-high-symbolic.svg"
"apps/scalable/gnome-logout.svg" "apps/scalable/gnome-logout.svg"
"apps/scalable/lock.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/sleep.svg"
"apps/scalable/kolourpaint.svg" # paint app "apps/scalable/kolourpaint.svg" # paint app
"panel/sensors-temperature-symbolic.svg" # temperature panel icon "panel/sensors-temperature-symbolic.svg" # temperature panel icon