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 question sits at the center of NoxKey's security model. An AI agent needs your Stripe key to make API calls. A human needs it to paste somewhere. Both call noxkey get. But the response should be fundamentally different — because what happens after delivery 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.

We spent two weeks building the process tree 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

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.

Dual verification: never trust the client

The CLI performs process-tree detection client-side. But the CLI is a binary on the user's machine. A malicious script could bypass it 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 — a kernel-level credential, not something the client sends. The server then walks that process tree independently.

Both sides must agree. If the CLI says "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.

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 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 return 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 — containing export KEY=value 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 subprocesses — 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. Created with 0600 permissions (owner-only). The 60-second cleanup 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 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 scenario:

  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 exits. PID 48201 is now free
  4. An attacker spawns a new process. macOS assigns it PID 48201 — recycled
  5. The attacker calls noxkey get 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 calls unlock, 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.

Command-level blocking for AI agents

Process tree detection enables granular access control. When the caller is an agent, certain commands are blocked:

  • --raw — no plaintext stdout. Agents can't pipe raw values
  • --copy — no clipboard access for agents
  • load, export, bundle, env — no bulk secret operations

And certain commands remain available:

  • ls — agents can discover key names (no values shown)
  • peek — agents can see 8-character prefixes for verification
  • get — returns encrypted handoff, not raw values
  • set --clipboard — agents can store secrets from your clipboard

The CLI exits with a clear error: "This command is not available to AI agents." No ambiguity. The agent knows exactly why it was blocked 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 agent access patterns is categorically better than zero detection. The most common ways developers leak credentials are all mitigated by this approach, even with its 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, 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 AI coding tool signatures. Combined with dual verification (client + server via LOCAL_PEERPID), 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.