All posts 🌳

How We Built Process-Tree Agent Detection

How do you tell if a human or an AI agent is requesting a secret?

This is the question that sits at the center of NoxKey's security model. An AI agent needs your Stripe key to make API calls. A human needs your Stripe key to paste it somewhere. Both call noxkey get. But the response should be fundamentally different — because what happens after the secret is delivered depends entirely on who's asking.

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.

I spent two weeks building the detection system that powers NoxKey's agent access control. Here's exactly how it works, where it breaks, and why imperfect detection still beats no detection at all.

CLI noxkey get 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 command, the chain looks like this:

launchd PID 1
  └─ claude ← Electron app (MATCH)
    └─ node ← Claude Code runtime
      └─ zsh ← spawned shell
        └─ noxkey ← get org/proj/STRIPE_KEY

When a human runs the same command from Terminal:

launchd PID 1
  └─ Terminal.app
    └─ zsh ← login shell
      └─ noxkey ← get org/proj/STRIPE_KEY

Spot the difference. In the first tree, there's a process named claude. In the second, there isn't. That's the signal.

Walking the 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 is straightforward: start at the requesting process, get its parent PID, check the binary name, move up, repeat. Stop when you hit PID 1 or find a 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. I benchmarked it across 500 calls — average was 0.8ms, worst case 1.6ms. You don't notice it.

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

The signatures list

The detection checks each ancestor's binary name against a list of known agent 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.

Is this a cat-and-mouse game? In theory, yes. An agent could rename its binary to definitely-not-an-agent and bypass detection. In practice, this doesn't happen. 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, that would be a PR disaster — "Cursor caught disguising itself to bypass credential controls" writes itself.

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.

Dual verification: don't trust the client

The CLI performs process-tree detection client-side. But the CLI is a binary running on the user's machine. A malicious script could bypass the CLI entirely and talk to the Unix socket directly, claiming to be human.

So the server verifies independently.

How dual verification works
When the CLI connects to NoxKey's Unix domain socket, the server resolves the peer's PID using the LOCAL_PEERPID socket option on macOS. This is a kernel-level credential — not something the client sends, but something the kernel reports for the socket connection. The server then walks that process tree independently of whatever the CLI claims about itself.

When the CLI connects to NoxKey's Unix domain socket, the server resolves the peer's PID using socket credentials (LOCAL_PEERPID socket option on macOS). It then walks that process tree — not the PID the client claims, but the actual PID the kernel reports for the socket connection.

Both sides must agree. If the CLI says "I'm a human" but the server sees claude in the ancestry, the server's verdict wins. The more restrictive interpretation always takes precedence. A compromised CLI can't downgrade its own classification.

This is the 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.

Here's the handoff sequence:

1. Generate Key ← random AES-256-CBC key + IV
  └─ 2. Encrypt ← secret value encrypted with one-time key
    └─ 3. Transmit ← payload + key + IV over Unix socket
      └─ 4. Decrypt ← CLI decrypts via CommonCrypto
        └─ 5. Write Script ← self-deleting temp script to /tmp (0600)
          └─ 6. Output ← source '/tmp/noxkey_abc123.sh' to stdout
            └─ 7. Cleanup ← file removed after 60s safety net
  1. The server generates a random AES-256-CBC key and initialization vector
  2. The secret value is encrypted with this one-time key
  3. The encrypted payload, key, and IV are returned to the CLI over the Unix socket
  4. The CLI decrypts using CommonCrypto (Apple's native crypto framework)
  5. The CLI writes a self-deleting temp script to /tmp — the script contains an export KEY=value line followed by rm -f "$0"
  6. The CLI outputs source '/tmp/noxkey_abc123.sh' to stdout
  7. A background cleanup process removes the file after 60 seconds regardless

The agent runs eval "$(noxkey get org/proj/STRIPE_KEY)". The eval sources the temp script, which exports the secret into the shell environment and deletes itself. The secret is now in $STRIPE_KEY — available to any subprocess the agent launches — but the raw value never appeared in the agent's conversation context. It flowed through the OS, not through the chat.

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

The PID recycling attack

PID Recycling Attack Scenario
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. This is a well-documented TOCTOU (time-of-check-time-of-use) vulnerability class.

Here's an attack scenario that kept me up at night.

NoxKey has session unlock: run noxkey unlock org/proj, authenticate with Touch ID once, and subsequent get calls under that prefix skip biometric auth for a configurable window. The session is bound to the PID that initiated it.

The attack:

  1. A legitimate process (PID 48201) calls noxkey unlock 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 finishes and exits. PID 48201 is now free
  4. An attacker spawns a new process. macOS assigns it PID 48201 — the same number, recycled
  5. The attacker calls noxkey get org/proj/DATABASE_URL from PID 48201
  6. The session manager sees PID 48201, finds an active session, skips Touch ID
  7. The attacker gets the secret without ever authenticating

On a busy system, PIDs can recycle within seconds. macOS uses a 99999 PID space. This isn't theoretical — it's a well-documented class of TOCTOU (time-of-check-time-of-use) vulnerability.

The fix: sessions are bound to PID and process start time. When a process calls unlock, the session manager records the PID and the boot-relative start timestamp from kp_proc.p_starttime (retrieved 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.

Command-level blocking

Detection enables granular access control. When the caller is an agent, certain commands are blocked entirely:

And certain commands remain available:

The CLI detects the caller's process tree and exits with a clear error message: "This command is not available to AI agents." No ambiguity, no silent failures. The agent knows exactly why it was blocked and can tell the user.

Honest limitations

Process-tree detection isn't perfect. I want to be upfront about where it breaks.

Name-based matching has blind spots. A new agent that isn't in the signatures list won't be detected. We update the list with each release, but there's always a window. If you're using an obscure agent, it gets treated as a human caller.

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 of process-tree walking 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 if an attacker has root on your machine, your Keychain secrets are already compromised regardless of NoxKey.

Despite all of this — 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 agent access patterns is categorically better than zero detection. The six most common ways agents leak secrets are all mitigated by this approach, even with its limitations.

We're not trying to build an unbreakable wall. We're trying to make the default safe.

We're not trying to build an unbreakable wall. We're trying to make the default safe. When a developer installs NoxKey and an AI agent requests a secret, the right thing happens automatically — no configuration, no flags, no awareness required. That's the bar. Process-tree detection clears it.

Key Takeaway
Process-tree agent detection uses macOS kernel APIs to walk from the requesting process to its ancestors, checking binary names against known agent signatures. Combined with dual verification (client-side + server-side via LOCAL_PEERPID), encrypted handoff delivery, and PID+start-time session binding, it provides a practical defense layer that catches 95% of real-world agent access patterns — without requiring any configuration from the developer.

If you want to see what this looks like in practice, the introduction post walks through the full install-to-usage flow.