All posts 🌳

How We Built Process-Tree Agent Detection

How do you tell if a human or an AI agent is requesting a secret when the call comes in over a raw shell, with no MCP envelope to inspect?

For agents that go through NoxKey's bundled MCP server, this question is easy — every noxkey_get arrives with the calling tool already identified, raises a per-request approval card, and gets routed through encrypted handoff. But not every caller is an MCP client. A build script, a manual source from a shell prompt, a Makefile that pulls a credential before running terraform apply — those land at the menu bar app over a Unix domain socket as a plain shell request, with no agent metadata attached.

That's where process-tree detection earns its keep. It's the fallback layer that catches AI agents reaching the Keychain through a shell instead of through MCP. A human uses the value and moves on. An agent ingests it into a conversation context where it can be logged, echoed in debug output, included in generated code, or stored in a chat history on someone else's server. Same secret, wildly different risk profiles — and detection has to work without help from the caller.

We spent two weeks building the process tree detection system that NoxKey uses for non-MCP shell callers. Here's exactly how it works, where it breaks, and why imperfect detection still beats no detection at all.

Shell caller build script, source Agent Detection Process Tree Walker Dual Verification Unix Socket Menu Bar App Server + Touch ID Keychain Encrypted

Every process has a family tree

Every process on macOS has a parent. Your shell was started by Terminal.app. Terminal.app was started by launchd. When you type a command, your shell forks a child process to run it. This chain — child to parent to grandparent — is the process tree.

When Claude Code runs a build script that pulls a secret over the menu bar app's socket, the chain looks like this:

launchd PID 1
  └─ claude ← Electron app (MATCH)
    └─ node ← Claude Code runtime
      └─ zsh ← spawned shell
        └─ deploy.sh ← requests org/proj/STRIPE_KEY

When a human runs the same script from Terminal:

launchd PID 1
  └─ Terminal.app
    └─ zsh ← login shell
      └─ deploy.sh ← requests org/proj/STRIPE_KEY

The difference: one tree has a process named claude. The other doesn't. That's the signal.

Walking the process tree in Swift

NoxKey's server is a native Swift menu bar app. The process tree walker uses macOS kernel APIs — specifically proc_pidinfo and sysctl with KERN_PROC — to climb from any PID to launchd.

The algorithm: start at the requesting process, get its parent PID, check the binary name, move up, repeat. Stop at PID 1 or on match.

func isAgentProcess(pid: pid_t) -> Bool {
    var currentPid = pid
    var depth = 0
    let maxDepth = 20  // safety limit

    while currentPid > 1 && depth < maxDepth {
        guard let name = processName(for: currentPid) else { break }

        let lower = name.lowercased()
        for signature in agentSignatures {
            if lower.contains(signature) {
                return true
            }
        }

        guard let parentPid = parentPID(for: currentPid),
              parentPid != currentPid else { break }
        currentPid = parentPid
        depth += 1
    }
    return false
}

The processName(for:) call uses proc_pidinfo with PROC_PIDTBSDINFO to get the binary name from the kernel's process table. No shelling out to ps, no reading /proc. Direct kernel query. On Apple Silicon, walking 20 ancestors takes under 2ms. We benchmarked across 500 calls — average 0.8ms, worst case 1.6ms. Imperceptible.

0.8ms
average detection time
1.6ms
worst case
20
ancestors max depth

The agent signatures list

Detection checks each ancestor's binary name against known AI coding tool signatures:

private let agentSignatures = [
    "claude", "cursor", "codex",
    "windsurf", "copilot", "cody",
    "aider", "continue", "tabby"
]

Case-insensitive substring matching. If any ancestor's binary name contains any of these strings, the caller is classified as an agent.

Could an agent rename its binary to bypass detection? In theory, yes. In practice, agent binaries are code-signed, distributed through Homebrew or app stores, and installed to standard paths. Users don't rename them. And if an agent vendor deliberately tried to evade detection, the headline writes itself: "Cursor caught disguising itself to bypass credential controls."

Name-based detection works because the incentives align. Agent vendors want to be identified. Being detected means getting the encrypted handoff instead of being blocked entirely. The alternative — no detection, no access — is worse for everyone.

Server-side verification: never trust the client

A shell caller could lie about who it is — a malicious script could open the Unix socket directly and claim to be human, or rename its parent process to evade detection. So the menu bar app never asks the client to identify itself. It checks independently.

How peer-PID verification works
When any client connects to NoxKey's Unix domain socket, the server resolves the peer's PID using the LOCAL_PEERPID socket option — a kernel-level credential, not something the client sends. The server then walks that process tree independently to look for agent signatures.

The verdict is the server's alone. If the parent chain contains claude, cursor, or any other known agent runtime, the request is classified as agent-originated regardless of what the client claims. The more restrictive interpretation always takes precedence.

Same principle as server-side validation in web apps. The client can lie. The server checks anyway.

The encrypted handoff

When detection confirms an agent caller, NoxKey doesn't refuse the secret. It changes how the secret is delivered. This is the critical design decision: agents need secrets to function. Blocking them entirely just pushes developers back to .env files. Instead, we make the secret available to the agent's process without exposing the raw value in its text context.

The handoff sequence:

1. Generate Key ← random ChaChaPoly key + nonce
  └─ 2. Encrypt ← secret value encrypted with one-time key
    └─ 3. Write Script ← self-deleting temp script to /tmp (0600)
      └─ 4. Return Path ← source '/tmp/noxkey-mcp-xyz/secrets.sh'
        └─ 5. Source ← caller sources script in Bash
          └─ 6. Cleanup ← single-use; safety net removes if never sourced
  1. The menu bar app generates a random ChaChaPoly key and nonce
  2. The secret value is sealed with this one-time key (ChaChaPoly via Apple's CryptoKit)
  3. A self-deleting script is written to a private /tmp directory with 0600 permissions — containing the export line followed by rm -f "$0"
  4. The response carries the source '/tmp/noxkey-mcp-xyz/secrets.sh' path — never the raw value, never the key material
  5. The caller (an MCP-using agent or a shell script) sources the script in Bash. The export line loads the secret into the environment and the script removes itself
  6. A background sweep removes any handoff that's never sourced after a short safety window

For an MCP agent, this means noxkey_get returns a path the agent runs once in Bash; for a shell caller, it's the same path returned over the socket. Either way, the secret reaches $STRIPE_KEY in the calling shell — available to subprocesses — but the raw value never appears in the agent's conversation context. It flows through the OS, not through the chat.

The temp file exists on disk for milliseconds. Created with 0600 permissions (owner-only). The cleanup sweep is a safety net for cases where the script isn't sourced.

Defending against PID recycling attacks

PID Recycling Attack
A legitimate process authenticates with Touch ID and gets a session. When it exits, macOS can recycle its PID. A new process inheriting that PID could hijack the authenticated session — accessing secrets without ever touching the fingerprint sensor. On a busy system with a 99999 PID space, recycling can happen within seconds.

NoxKey has implicit session unlock: an agent (or shell caller) requests every secret under a prefix in one shot — for example, noxkey_get(account: "org/proj", session: "4h") — authenticates with Touch ID once, and every key under that prefix loads into the shell environment. Subsequent fetches under the same prefix skip biometric auth for the session window. The session is bound to the process tree that initiated it.

The attack scenario, if sessions were keyed only on PID:

  1. A legitimate process (PID 48201) requests org/proj and authenticates with Touch ID
  2. The session manager records: "PID 48201 has an active session for org/proj/*"
  3. The legitimate process exits. PID 48201 is now free
  4. An attacker spawns a new process. macOS assigns it PID 48201 — recycled
  5. The attacker requests org/proj/DATABASE_URL from PID 48201
  6. The session manager sees the PID, finds an active session, skips Touch ID
  7. The attacker gets the secret without ever authenticating

The fix: sessions are bound to PID and process start time. When a process opens a session, the session manager records the PID and the boot-relative start timestamp from kp_proc.p_starttime (via sysctl with KERN_PROC). Every subsequent request checks both. A recycled PID has a different start time — microsecond precision makes collisions effectively impossible. The session check rejects it, and Touch ID is required again.

Operation-level blocking for AI agents

Process-tree detection enables granular access control. When the caller is an agent, the menu bar app refuses any operation that would dump a raw value or bulk-export the Keychain — even over the shell socket. Variants like --raw, --copy, load, env, export, and bundle that older shell wrappers used to support are hard-blocked when an AI agent is detected anywhere in the parent chain. There is no plaintext stdout for an agent caller; there is no bulk extraction.

What remains available — both through the bundled MCP tools and through any equivalent shell request — is the same set of safe operations:

If an agent reaches for a blocked operation, the response is explicit: "This operation is not available to AI agents." No ambiguity. The agent knows why it was refused and can tell the user.

Honest limitations of process tree detection

This approach isn't perfect. We want to be upfront about where it breaks.

Name-based matching has blind spots. A new agent not in the signatures list won't be detected. We update the list with each release, but there's always a window. Obscure agents get treated as human callers.

Detection is point-in-time. It happens when the secret is requested. If an agent already has a secret in its environment from a previous session — before NoxKey was installed, or from a .env file it read earlier — detection can't revoke that access.

This is macOS only. The implementation uses proc_pidinfo, sysctl, and LOCAL_PEERPID — all macOS-specific APIs. The concept is portable to Linux (via /proc) and Windows (via NtQueryInformationProcess), but this code isn't.

Sophisticated evasion is possible. An attacker with root access could manipulate process names or inject into a legitimate process. But root access means your Keychain secrets are already compromised regardless.

Despite these limitations — no other secrets manager distinguishes between human and agent callers at all. Every .env file, every 1password read call, every vault kv get treats all callers identically. Imperfect detection that catches 95% of real-world shell-based agent access is categorically better than zero detection — and it sits behind the MCP path, not in front of it. Agents that go through noxkey_get are already identified explicitly; process-tree detection is the safety net for the build scripts, Makefiles, and ad-hoc source calls that bypass MCP entirely. The most common ways developers leak credentials are mitigated by the combination, even with each layer's limitations.

We're not building an unbreakable wall. We're making the default safe.

When a developer installs NoxKey and an AI agent requests a secret — whether through MCP or through a shell script the agent kicked off — the right thing happens automatically. No configuration, no flags, no awareness required. That's the bar. Process-tree detection clears it for the shell path; MCP delivery clears it for everything else.

Key Takeaway
Process-tree agent detection is the fallback layer for non-MCP shell callers — build scripts, Makefiles, manual source calls. The menu bar app uses macOS kernel APIs to walk from the requesting process to its ancestors, checking binary names against known AI coding tool signatures. Combined with peer-PID verification (LOCAL_PEERPID on the Unix socket), encrypted handoff delivery, and PID+start-time session binding, it catches 95% of real-world agent access patterns — without requiring any configuration from the developer.