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