Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/fix-no-tty-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: patch
---

fix biometric session scoping for non-TTY processes
5 changes: 5 additions & 0 deletions .bumpy/ts-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
env-spec-language: none
---

fix ts check issue
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
- name: ESLint
run: bun run lint
- name: TypeScript type check
run: bun run typecheck
run: bun run typecheck:all
- name: Build libraries
run: bun run build:libs
- name: Run tests
Expand Down
5 changes: 0 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@
- This monorepo uses **bumpy** (`@varlock/bumpy`) for version management
- Changeset files live in `.bumpy/` and are created with `bunx @varlock/bumpy add` (or `bun run bumpy:add`)
- Standard bump types: `major`, `minor`, `patch`
- **Isolated bump types**: `minor-isolated` and `patch-isolated` are natively supported
- These suppress dependency propagation — the package itself gets bumped but dependents are **not** automatically bumped
- Use **`minor-isolated`** for minor bumps that don't affect the library API consumed by dependents (e.g., CLI-only features in `varlock` that plugins/integrations don't depend on). This is the most common use case — because all packages are still on `0.x`, `^0.y.z` ranges treat minor bumps as out-of-range, which would otherwise cascade bumps to all dependents.
- `patch-isolated` exists but is rarely needed — patch bumps on `0.x` stay within `^` ranges and don't cascade
- `major-isolated` is intentionally **not** supported (major bumps must propagate to keep semver ranges valid)
- Non-interactive changeset creation (for CI/AI): `bumpy add --packages "pkg:minor" --message "description" --name "changeset-name"`
- Bump files are only required when publishable packages have changed (based on `changedFilePatterns` in `.bumpy/_config.json`). Changes to CI workflows, root config files, scripts, docs, etc. do **not** require a bump file — bumpy's pre-push hook will not block in that case.

Expand Down
129 changes: 111 additions & 18 deletions packages/encryption-binary-rust/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@
continue;
}

// Get peer TTY identity
let tty_id = get_peer_tty_id(&stream);
// Get peer session identity
let tty_id = get_peer_session_id(&stream);

std::thread::spawn(move || {
handle_client(stream, handler, on_activity, running, tty_id);
Expand Down Expand Up @@ -398,10 +398,10 @@
true
}

// ── Peer TTY identity (Linux) ────────────────────────────────────
// ── Peer session identity (Linux) ───────────────────────────────

#[cfg(target_os = "linux")]
fn get_peer_tty_id(stream: &UnixStream) -> Option<String> {
fn get_peer_session_id(stream: &UnixStream) -> Option<String> {
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
use std::os::fd::AsFd;

Expand All @@ -412,19 +412,15 @@
return None;
}

// Read the process's controlling terminal from /proc
get_tty_for_pid(pid as u32)
// Prefer TTY-based identity, fall back to process tree
get_tty_session_id(pid as u32)
.or_else(|| get_ptree_session_id(pid as u32))
}

/// TTY-based session identity: tty device + session leader start time.
#[cfg(target_os = "linux")]
fn get_tty_for_pid(pid: u32) -> Option<String> {
// Read /proc/<pid>/stat to get the tty_nr field (field 7, 0-indexed 6)
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;

// The stat line format is: pid (comm) state ppid pgrp session tty_nr ...
// comm can contain spaces and parens, so find the last ')' first
let after_comm = stat.rfind(')')? + 2;
let fields: Vec<&str> = stat[after_comm..].split_whitespace().collect();
fn get_tty_session_id(pid: u32) -> Option<String> {
let fields = parse_proc_stat(pid)?;

// After the closing paren: state(0) ppid(1) pgrp(2) session(3) tty_nr(4)
let tty_nr: u32 = fields.get(4)?.parse().ok()?;
Expand All @@ -443,20 +439,61 @@
let minor = (tty_nr & 0xff) | ((tty_nr >> 12) & 0xfff00);
let tty_name = format!("tty{major}:{minor}");

Some(format!("{tty_name}:{start_time}"))
Some(format!("tty:{tty_name}:{start_time}"))
}

/// Process-tree-based session identity for non-TTY processes.
/// Mirrors the macOS Swift daemon logic: walks the ancestry chain up to PID 1,
/// then uses the grandchild of the root as a stable scope key.
#[cfg(target_os = "linux")]
fn get_process_start_time(pid: u32) -> Option<u64> {
fn get_ptree_session_id(pid: u32) -> Option<String> {
let mut chain: Vec<u32> = vec![pid];
let mut current = pid;

for _ in 0..64 {
let ppid = get_parent_pid(current)?;
if ppid <= 1 {
break;
}
chain.push(ppid);
current = ppid;
}

// Need at least 4 levels for a meaningful intermediate ancestor
if chain.len() < 4 {
return None;
}

let scope_pid = chain[chain.len() - 3];
let start_time = get_process_start_time(scope_pid).unwrap_or(0);
Some(format!("ptree:{scope_pid}:{start_time}"))
}

/// Parse /proc/<pid>/stat and return the fields after the comm closing paren.
#[cfg(target_os = "linux")]
fn parse_proc_stat(pid: u32) -> Option<Vec<String>> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rfind(')')? + 2;
let fields: Vec<&str> = stat[after_comm..].split_whitespace().collect();
Some(stat[after_comm..].split_whitespace().map(|s| s.to_string()).collect())
}

/// Get the PPID for a given process from /proc.
#[cfg(target_os = "linux")]
fn get_parent_pid(pid: u32) -> Option<u32> {
let fields = parse_proc_stat(pid)?;
// After comm: state(0) ppid(1)
fields.get(1)?.parse().ok()
}

#[cfg(target_os = "linux")]
fn get_process_start_time(pid: u32) -> Option<u64> {
let fields = parse_proc_stat(pid)?;
// Field 19 after comm is starttime (in clock ticks since boot)
fields.get(19)?.parse().ok()
}

#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn get_peer_tty_id(_stream: &UnixStream) -> Option<String> {
fn get_peer_session_id(_stream: &UnixStream) -> Option<String> {
None
}

Expand Down Expand Up @@ -728,3 +765,59 @@

Ok(())
}

// ── Tests ───────────────────────────────────────────────────────

#[cfg(test)]
#[cfg(target_os = "linux")]
mod tests {
use super::*;

#[test]
fn test_parse_proc_stat_self() {
let fields = parse_proc_stat(std::process::id()).expect("should parse own /proc/stat");
// Should have at least 20 fields (we read up to field 19 for starttime)
assert!(fields.len() >= 20, "expected >=20 fields, got {}", fields.len());
// Field 0 is state (single char like R, S, etc.)
assert_eq!(fields[0].len(), 1);
// Field 1 is ppid (should be > 0)
let ppid: u32 = fields[1].parse().expect("ppid should be a number");
assert!(ppid > 0);
}

#[test]
fn test_get_parent_pid() {
let ppid = get_parent_pid(std::process::id()).expect("should get own ppid");
assert!(ppid > 1, "test process ppid should be > 1");
}

#[test]
fn test_get_process_start_time() {
let st = get_process_start_time(std::process::id()).expect("should get own start time");
assert!(st > 0);
}

#[test]
fn test_get_ptree_session_id_self() {
// The test runner process should have a deep enough chain
// (cargo test → test binary → ... → init), but the exact depth
// depends on the environment. Just verify it returns Some or None
// without panicking, and if Some, has the right format.
if let Some(id) = get_ptree_session_id(std::process::id()) {
assert!(id.starts_with("ptree:"), "expected ptree: prefix, got {id}");

Check failure

Code scanning / CodeQL

Cleartext logging of sensitive information High

This operation writes
get_ptree_session_id(...)
to a log file.
Comment thread
theoephraim marked this conversation as resolved.
Dismissed
let parts: Vec<&str> = id.split(':').collect();
assert_eq!(parts.len(), 3, "expected ptree:pid:starttime, got {id}");

Check failure

Code scanning / CodeQL

Cleartext logging of sensitive information High

This operation writes
get_ptree_session_id(...)
to a log file.
Comment thread
theoephraim marked this conversation as resolved.
Dismissed
let _pid: u32 = parts[1].parse().expect("pid should be a number");
let _st: u64 = parts[2].parse().expect("start time should be a number");
}
}

#[test]
fn test_get_tty_session_id_format() {
// May or may not have a TTY depending on how tests are run.
// Just verify it doesn't panic and has correct format if present.
if let Some(id) = get_tty_session_id(std::process::id()) {
assert!(id.starts_with("tty:"), "expected tty: prefix, got {id}");

Check failure

Code scanning / CodeQL

Cleartext logging of sensitive information High

This operation writes
get_tty_session_id(...)
to a log file.
Comment thread
theoephraim marked this conversation as resolved.
Dismissed
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ final class IPCServer {
private var isRunning = false

/// Handler for incoming messages. Second parameter is the peer's TTY identity (nil if unknown).
var messageHandler: ((_ message: [String: Any], _ ttyId: String?) -> [String: Any])?
var messageHandler: ((_ message: [String: Any], _ sessionId: String?) -> [String: Any])?

/// Called after accept (new client) and after each successfully parsed JSON message.
var onConnectionActivity: (() -> Void)?
Expand All @@ -41,10 +41,21 @@ final class IPCServer {
guard lockFD >= 0 else {
throw IPCError.socketCreationFailed("Failed to create lock file: \(String(cString: strerror(errno)))")
}
guard flock(lockFD, LOCK_EX | LOCK_NB) == 0 else {
if flock(lockFD, LOCK_EX | LOCK_NB) != 0 {
// Lock held by another process (possibly stuck in UE state).
// Delete the lock file and reopen — the new file gets a fresh inode
// so the old process's flock (tied to the old inode) doesn't block us.
close(lockFD)
lockFD = -1
throw IPCError.socketCreationFailed("Another daemon instance holds the lock")
unlink(lockPath)
lockFD = open(lockPath, O_CREAT | O_RDWR, 0o600)
guard lockFD >= 0 else {
throw IPCError.socketCreationFailed("Failed to recreate lock file: \(String(cString: strerror(errno)))")
}
guard flock(lockFD, LOCK_EX | LOCK_NB) == 0 else {
close(lockFD)
lockFD = -1
throw IPCError.socketCreationFailed("Another daemon instance holds the lock")
}
}

// Clean up any stale socket file (safe now — we hold the lock)
Expand Down Expand Up @@ -169,12 +180,12 @@ final class IPCServer {
}
}

// Resolve the peer's TTY identity once per connection
let ttyId: String?
// Resolve the peer's session identity once per connection
let sessionId: String?
if let peerPid = getPeerPid(fd: fd) {
ttyId = getTtyIdentifier(forPid: peerPid)
sessionId = getSessionIdentifier(forPid: peerPid)
} else {
ttyId = nil
sessionId = nil
}

while isRunning {
Expand Down Expand Up @@ -206,7 +217,7 @@ final class IPCServer {
onConnectionActivity?()

// Handle message with the peer's TTY identity
let response = messageHandler?(json, ttyId) ?? ["error": "No handler"]
let response = messageHandler?(json, sessionId) ?? ["error": "No handler"]
sendResponse(fd: fd, id: json["id"] as? String, response: response)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,43 +30,94 @@ private func getProcessInfo(pid: pid_t) -> kinfo_proc? {
return info
}

/// Get a stable TTY identifier for a process.
/// Get the PPID for a given process.
private func getParentPid(pid: pid_t) -> pid_t? {
guard let info = getProcessInfo(pid: pid) else { return nil }
let ppid = info.kp_eproc.e_ppid
return ppid > 0 ? ppid : nil
}

/// Get the start time (seconds since epoch) for a given process.
private func getStartTime(pid: pid_t) -> Int {
guard let info = getProcessInfo(pid: pid) else { return 0 }
return Int(info.kp_proc.p_starttime.tv_sec)
}

/// Get a stable session identifier for a process.
///
/// Prefers the controlling TTY (combined with the session leader's start time
/// to prevent TTY device reuse attacks). When no TTY is available (e.g.,
/// processes spawned by VSCode/Cursor extensions, background agents, etc.),
/// walks up the process tree to find a stable ancestor for session scoping.
///
/// Combines the TTY device name with the session leader's start time.
/// The session leader is the shell process that owns the TTY (its PID equals
/// the session ID). Using its start time prevents TTY device reuse attacks
/// (where a new terminal is allocated the same /dev/ttysNNN after the old one closed).
/// The no-TTY algorithm finds the "app root" (ancestor whose PPID is 1/launchd),
/// then uses the grandchild of that app root in the peer's ancestry chain.
/// This scopes sessions narrowly — e.g., for Cursor, each Claude Code instance
/// gets its own session, while a malicious extension in the same window cannot
/// piggyback. If the tree is too shallow (peer is a direct child or grandchild
/// of the app root), returns nil (no caching, fresh auth each time).
///
/// Returns nil if the process has no controlling TTY (detached, CI, etc).
func getTtyIdentifier(forPid pid: pid_t) -> String? {
/// Returns nil if no stable identity can be determined.
func getSessionIdentifier(forPid pid: pid_t) -> String? {
guard let info = getProcessInfo(pid: pid) else { return nil }

// e_tdev is dev_t (Int32). NODEV is -1 in signed representation
// (0xFFFFFFFF unsigned). Comparing Int32(-1) != UInt32.max is true in
// Swift's BinaryInteger comparison, so we must compare in the same type.
let ttyDev = info.kp_eproc.e_tdev
// NODEV (0xFFFFFFFF) or 0 means no controlling tty
guard ttyDev != UInt32.max, ttyDev != 0 else { return nil }

// Convert device number to name (e.g., "ttys003")
guard let namePtr = devname(dev_t(ttyDev), S_IFCHR) else { return nil }
let ttyName = String(cString: namePtr)

// Get the session leader's start time for uniqueness.
// getsid() returns the session leader PID (the shell that owns the TTY),
// which is stable across all processes launched from the same terminal.
// (e_tpgid is the *foreground process group*, which changes on every command.)
let sessionLeaderPid = getsid(pid)
var startTimestamp: Int = 0

if sessionLeaderPid > 0, let leaderInfo = getProcessInfo(pid: sessionLeaderPid) {
startTimestamp = Int(leaderInfo.kp_proc.p_starttime.tv_sec)
let hasTty = ttyDev > 0

if hasTty {
// TTY-based identity: device name + session leader start time
guard let namePtr = devname(dev_t(ttyDev), S_IFCHR) else { return nil }
let ttyName = String(cString: namePtr)

let sessionLeaderPid = getsid(pid)
var startTimestamp: Int = 0
if sessionLeaderPid > 0, let leaderInfo = getProcessInfo(pid: sessionLeaderPid) {
startTimestamp = Int(leaderInfo.kp_proc.p_starttime.tv_sec)
}
if startTimestamp == 0 {
startTimestamp = Int(info.kp_proc.p_starttime.tv_sec)
}

return "tty:\(ttyName):\(startTimestamp)"
}

// If we couldn't get the session leader start time, fall back to the
// connecting process's own start time (less ideal but still unique per session)
if startTimestamp == 0 {
startTimestamp = Int(info.kp_proc.p_starttime.tv_sec)
// No TTY — walk up the process tree to find a scoping ancestor.
//
// Build the ancestry chain from the peer up to (but not including) PID 1.
// Example chain for Claude in Cursor:
// [node/bun, zsh, claude, extension-host, Cursor]
// indices: 0 1 2 3 4
//
// The last element is the "app root" (PPID=1).
// We use the element at index (count - 3) — the grandchild of the app root.
// This gives us per-tool scoping (e.g., the Claude binary), which is narrow
// enough that other extensions can't piggyback, but stable across multiple
// commands spawned by that tool.
//
// If the chain is too short (< 4 elements), we can't determine a stable
// intermediate ancestor, so we return nil (no caching).

var chain: [pid_t] = [pid]
var current = pid
// Walk up with a depth limit to avoid infinite loops
for _ in 0..<64 {
guard let ppid = getParentPid(pid: current), ppid > 1 else { break }
chain.append(ppid)
current = ppid
}

return "\(ttyName):\(startTimestamp)"
// Need at least 4 levels: peer → intermediate → scope-target → app-child → app-root
// so that scope-target is a meaningful intermediate process
guard chain.count >= 4 else { return nil }

// The grandchild of the app root: 2 levels below the last element
let scopePid = chain[chain.count - 3]
let startTime = getStartTime(pid: scopePid)

return "ptree:\(scopePid):\(startTime)"
}

// MARK: - Process Verification
Expand Down
Loading
Loading