fix(capture): restore key overlays after hardening

Validate the systemd runtime path using the process effective UID instead of shell ownership probes that fail in Hyprland's embedded Lua runtime. Keep Quickshell FileView watchers live by updating their fixed, private state paths in place and seed both state files when the hook loads.
This commit is contained in:
Bob Myrick
2026-08-25 21:20:05 -04:00
parent ee988956bb
commit 7b3fa2f0b3
+33 -34
View File
@@ -14,18 +14,29 @@
local function shell_quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end
local function effective_uid()
local f = io.open("/proc/self/status", "r")
if not f then return nil end
for line in f:lines() do
local uid = line:match("^Uid:%s+%d+%s+(%d+)")
if uid then
f:close()
return uid
end
end
f:close()
return nil
end
local function is_runtime_secure(r)
if not r or r == "" then return false end
if r == "/tmp" then return false end
if r:sub(1, 1) ~= "/" then return false end
local q = shell_quote(r)
-- Must be a directory owned by the current user and, if stat is
-- available, mode 0700 (systemd's XDG_RUNTIME_DIR default). The
-- stat check is skipped when stat is missing so we don't fail-closed
-- on minimal containers.
local cmd = "test -d " .. q .. " && test -O " .. q .. " && { p=$(stat -c %a " .. q .. " 2>/dev/null); [ -z \"$p\" ] || [ \"$p\" = \"700\" ]; }"
local res = os.execute(cmd)
return res == 0 or res == true
-- Shell ownership probes fail from Hyprland's embedded Lua environment.
-- /run/user is root-controlled, so accepting only this process's systemd
-- runtime path still excludes shared or caller-supplied directories.
local uid = effective_uid()
return uid ~= nil and r == "/run/user/" .. uid
end
local runtime = os.getenv("XDG_RUNTIME_DIR")
@@ -38,36 +49,19 @@ if not is_runtime_secure(runtime) then
runtime = nil
end
local STATE_FILE = runtime and (runtime .. "/omarchy-key-visualizer.json") or nil
local SUPER_FLAG = runtime and (runtime .. "/omarchy-key-visualizer-super") or nil
local function secure_write(path, content)
if not path then return false end
-- Reject the exact predictable fallback that the security review flagged.
-- A valid XDG_RUNTIME_DIR under /tmp with a random suffix and 0700 is
-- allowed because it passed is_runtime_secure().
if path == "/tmp/omarchy-key-visualizer.json" or path == "/tmp/omarchy-key-visualizer-super" then return false end
local q = shell_quote(path)
-- Refuse to follow a symlink at the destination (O_NOFOLLOW mitigation).
-- `test ! -L` succeeds when the file does not exist or is not a symlink;
-- it fails only when the destination is a symlink, which we must not follow.
local not_symlink = os.execute("test ! -L " .. q)
if not (not_symlink == 0 or not_symlink == true) then
print("[key-visualizer] refusing to write symlink: " .. path)
return false
end
local tmp = path .. ".tmp"
local f = io.open(tmp, "w")
if not runtime or (path ~= STATE_FILE and path ~= SUPER_FLAG) then return false end
-- FileView watches the existing inode, so replacing the path on every key
-- leaves Quickshell attached to an unlinked file. Updating in place keeps
-- the watcher live. The containing runtime directory is private to the
-- effective user and the two accepted paths are fixed above.
local f = io.open(path, "w")
if not f then return false end
f:write(content)
f:close()
os.execute("chmod 600 " .. shell_quote(tmp) .. " 2>/dev/null")
-- Atomic replace; avoids truncating a file that may have been swapped
-- between the symlink check and the open (TOCTOU mitigation).
local ok = os.rename(tmp, path)
if not ok then
os.execute("rm -f " .. shell_quote(tmp) .. " 2>/dev/null")
return false
end
os.execute("chmod 600 " .. q .. " 2>/dev/null")
os.execute("chmod 600 " .. shell_quote(path) .. " 2>/dev/null")
return true
end
@@ -200,7 +194,6 @@ end
-- Super-held flag: the panel/display watches this to know when to capture the
-- SUPER+drag on the overlay (instead of a window underneath). Written only on
-- transitions so it does not spam the filesystem on every key.
local SUPER_FLAG = runtime and (runtime .. "/omarchy-key-visualizer-super") or nil
local last_super = nil
local function super_down()
return pressed[133] or pressed[134]
@@ -213,6 +206,12 @@ local function emit_super()
secure_write(SUPER_FLAG, down and "1" or "0")
end
-- Create both files when the hook loads. This clears stale state after a
-- compositor restart and gives FileView stable paths to watch before the
-- first keyboard event arrives.
emit()
emit_super()
-- Combos: a combination of keys is treated as a unit. The display only
-- updates on key-down (the combo grows as you press) and when the last key
-- is released (the empty payload starts the panel's linger with the last