PID reuse corrupts agent session attribution
PID reuse can misattribute agent actions. Build safer macOS agent sessions with process start data, signer checks, parent snapshots, and strict revalidation.

A bare PID is acceptable for a status screen. It is not acceptable as the identity behind an approval, an audit record, or a credential-bearing agent session. The operating system recycles process IDs, and an agent system that treats a recycled number as the same actor can attach a later process to an earlier process's approval.
That bug does not need an attacker with kernel access or an exotic race. It appears when a short-lived agent exits, a busy machine creates enough processes to reuse its PID, and some component reconnects a call, refreshes a display, or resolves an old audit row by PID. The later process can be ordinary software. The attribution is still wrong, and an approval system that acts on wrong attribution has already lost the point of having approvals.
The fix is not complicated, but it does require a change in vocabulary. Stop calling a PID a process identity. Store a captured process record that contains the PID, its start time, its code-signing facts, and parent provenance collected at the time you made the authorization decision. Then make every later comparison prove that the live process still matches that record.
A PID names a slot, not a process lifetime
A process ID identifies a currently allocated kernel process entry. It does not name one program forever, and it does not carry a global uniqueness guarantee across time. When the process exits, its PID becomes eligible for reuse.
This distinction sounds fussy until a system lets a previous approval carry forward. Suppose an agent client starts as PID 4812, asks to use a deployment credential, and the user approves that run. The client exits. Later, another process receives PID 4812. If your gateway asks "does PID 4812 have an approved session?", it may retrieve the old approval and attach it to the new process.
The dangerous part is that the old and new process do not need to overlap. Many implementations make the mistake in a database join, a cache lookup, or a late enrichment job. They save an approval as pid = 4812, save calls the same way, and trust a later query to mean what it meant earlier. It does not.
A PID can still be useful in a human-facing record because it helps an operator inspect a live system. Keep it. Just do not make it the primary identity used for authorization or attribution.
There is a second trap: fork and exec complicate simplistic explanations. A fork creates a new child with a different PID. An exec replaces the program image in an existing process while retaining its PID. If you approve a shell runner and it execs another binary, the PID remains familiar while the code that will make the next request may not be. An identity design must account for both PID reuse after exit and program replacement within a surviving process.
Capture a process record when the session begins
A reliable session record is a snapshot taken at the security boundary, not a collection of facts reconstructed later. Capture it when the agent process first asks to create a session, before you display an approval that describes the caller.
For a macOS agent gateway, I would store these fields as one immutable process snapshot:
- PID and effective user ID.
- Process start time with seconds and microseconds when the platform supplies them.
- Executable path observed at capture time.
- Signing identifier, team or signer context, and code directory hash when available.
- Parent process snapshot, including its own PID and start time.
The process start time turns a PID into a lifetime-specific reference. A practical tuple looks like this:
process_instance = (
pid = 4812,
start_time = 2026-07-22T14:03:18.482911Z,
euid = 501
)
That tuple answers the narrow question, "Is this the same kernel process lifetime I saw before?" It does not answer whether the process is trustworthy. The signing record answers a different question: "What code did macOS validate when I inspected this process?" The parent snapshot answers another: "What created this process, as observed when the session began?"
Do not flatten those questions into one text label such as Claude Code (PID 4812). That label may be fine for an approval card, but it loses the facts you need when a reviewer asks why a particular session was authorized.
On macOS, a process inspector can use proc_pidinfo with PROC_PIDTBSDINFO to obtain proc_bsdinfo, which includes pbi_start_tvsec and pbi_start_tvusec. Apple also exposes process start_time in its Endpoint Security process model. The important part is not which API you choose. The important part is that you capture start time at the decision boundary and compare it later.
A narrow C helper for the lifetime portion can look like this:
#include <libproc.h>
#include <sys/proc_info.h>
#include <stdio.h>
int read_process_lifetime(pid_t pid) {
struct proc_bsdinfo info = {0};
int size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0,
&info, sizeof(info));
if (size != sizeof(info)) {
return -1;
}
printf("pid=%d start=%lld.%06d parent=%d uid=%d\n",
info.pbi_pid,
info.pbi_start_tvsec,
info.pbi_start_tvusec,
info.pbi_ppid,
info.pbi_uid);
return 0;
}
The output shape matters more than the language binding:
pid=4812 start=1784738598.482911 parent=4760 uid=501
If a later read returns the same PID with a different start timestamp, you are looking at a different process. Deny the session match, even if every convenience layer wants to treat the number as familiar.
Code signing describes code, not one running instance
Code-signing data adds useful provenance, but it does not turn into a process identity by magic. A signing identifier may be shared by every released version of an application. A team identifier identifies a signing organization, not a particular executable. A code directory hash is more specific to the signed code content, yet many concurrently running instances of the same executable will still share it.
Apple's code-signing documentation makes an important distinction that security products often blur. A signing identifier can be claimed by multiple signers, so Apple recommends checking the validation category and, for non-Apple code, the team identifier too. Apple's technical note TN3127 also explains that a designated requirement combines an identifier with signing requirements to establish code identity across updates.
That is the right mental model: signing facts tell you what executable macOS has validated and whether it meets your trust requirement. They do not tell you whether this is the same process that the user approved five minutes earlier.
For session authorization, use both layers:
same_process_lifetime:
pid, start_time, euid all match the captured record
same_expected_code:
captured signing requirement still validates for the live process
same_session:
the session token refers to this captured process record, not only its PID
Do not compare just a display name, a bundle identifier, or a path. Paths change. Unsigned command-line tools may have weak or local-only identity properties. A user can also run a copy of a familiar tool from a different location. Your approval UI can show a friendly label, but your authorization code should preserve the evaluated signing facts that led to that label.
A common bad recommendation says to authorize by signing identifier alone because it survives application updates. It is popular because update continuity feels convenient. It is wrong for a per-run approval. If a person approved one running agent process, a later launch of the same signed program is a new run and should receive a new session decision. Stable code identity is a reason to make the approval card readable. It is not permission to silently extend a one-run approval to every future instance.
Parent PID is evidence only when you preserve its lifetime
Parent process data helps reviewers understand how an agent started. It can distinguish a terminal-launched client from a process started by an editor, a scheduler, or another agent. But ppid = 4760 has the same reuse problem as the child's PID.
The wrong pattern is easy to spot:
session.agent_pid = 4812
session.parent_pid = 4760
Three hours later, an audit viewer resolves PID 4760 to a process with that number and labels it as the session's parent. The original parent may have exited long ago. A different process now owns 4760. The audit page has turned a historical claim into a live lookup and quietly rewritten history.
Capture the parent snapshot alongside the child snapshot:
parent_instance = (
pid = 4760,
start_time = 2026-07-22T14:01:02.117604Z,
euid = 501,
executable_path = "/usr/bin/login",
signing_requirement = "captured evaluation",
relationship = "observed_parent_at_session_open"
)
That final relationship field sounds mundane, but it prevents a lot of bad language. The parent record means "this was the direct parent when the system observed the child." It does not mean "this parent authorized the child," "this parent owns the child forever," or "this remains the current parent." Those claims require separate evidence.
If you have Endpoint Security process events, Apple's es_process_t supplies the original parent PID and exposes both parent_audit_token and responsible_audit_token, as well as start time and signing data. Audit tokens are preferable to raw PIDs when the API gives them to you because they preserve more context. Still, treat an event's process object as a captured observation. Do not swap it for a later PID lookup and call that equivalent.
For systems without Endpoint Security, use the best process APIs available, capture quickly, and make uncertainty visible. A parent may exit between reading the child and reading the parent. Do not invent certainty in that race. Mark the parent as unavailable or partial, retain the child identity that you did capture, and avoid claiming a parent relationship you could not observe.
Revalidate before an approved session performs an action
Session approval needs two moments of identity handling: capture when the session opens, and revalidation when the session uses authority. Capturing once protects the audit trail. Revalidating protects the next action.
Imagine this sequence:
- An agent process opens a session and you capture PID 4812, start time, signer facts, and parent details.
- A person approves that exact agent run.
- The process exits while the session token remains in memory or on a local IPC connection.
- A later process receives PID 4812 and presents a stale token or reaches an old association through a bug.
- Your gateway checks the live process before performing a credentialed call.
At the final check, the PID may match. The start time will not. That mismatch must close the session. Do not repair the record with the new timestamp, do not create a quiet replacement session, and do not attribute the action to the earlier process.
The check should fail closed when inspection fails too. If your code cannot read the process information because the process exited, permissions changed, or the operating system returns incomplete data, it cannot establish that the requester is the approved process. The correct response is to require a new session.
Keep the comparison narrow and literal. Do not use fuzzy matching such as "same command name," "same working directory," or "same terminal window." Those fields help a human understand context, but they do not survive adversarial or accidental ambiguity. A process can change its working directory, and two unrelated processes can choose the same command name.
The right place to cache is the authorization decision attached to the captured process instance. The wrong place to cache is a map from PID to approval state. A map keyed by PID will pass tests on a quiet laptop and fail under process churn, which makes it especially unpleasant to diagnose after it reaches users.
Model sessions as immutable events, not mutable process rows
A session journal should preserve what the gateway observed at each point in time. A mutable row that always says "PID 4812 is active" cannot answer whether its fields came from the original agent, a replacement process, or a late background refresh.
Use append-only event records with explicit references. The structure can be simple:
{
"event_type": "session_authorized",
"session_id": "sess_7d9f",
"process_instance": {
"pid": 4812,
"start_time": "2026-07-22T14:03:18.482911Z",
"euid": 501,
"signing_id": "com.example.agent",
"team_id": "A1B2C3D4E5",
"cdhash": "captured-code-directory-hash"
},
"parent_instance": {
"pid": 4760,
"start_time": "2026-07-22T14:01:02.117604Z"
},
"approval": {
"scope": "this process run",
"decision": "approved"
}
}
The next call event references sess_7d9f and records its own time, requested channel, action target, and result. It does not copy only pid: 4812 and hope an analyst can reconstruct the rest. If revalidation fails, write a separate denial event with the observed mismatch.
{
"event_type": "action_denied",
"session_id": "sess_7d9f",
"reason": "process_start_time_mismatch",
"captured_pid": 4812,
"captured_start_time": "2026-07-22T14:03:18.482911Z",
"observed_start_time": "2026-07-22T15:47:09.031882Z"
}
This gives a reviewer something concrete: the process number was reused, the gateway recognized the mismatch, and it denied the request. Without both timestamps, the event merely says that something failed. That is not enough when you need to tell a software defect from a hostile attempt to inherit authority.
Sallyport's split between a Sessions journal and an Activity journal is useful here because session-level authorization and individual actions answer different audit questions. Both need to retain the process snapshot that existed when the session decision was made, rather than relying on a bare PID during later projection or review.
The approval card should describe provenance without pretending certainty
Users cannot make a good decision from a dialog that says only "Agent wants access." They need enough identity context to recognize the caller, but you should not load the card with fields that look precise while saying little.
Lead with the code-signing authority that you evaluated. Include the executable name or path when it helps. Show the process relationship in plain language, such as "started by a signed terminal application" or "launched from an editor helper," only if you captured the supporting data. Include the PID as a troubleshooting detail, not as the claimed identity.
Avoid presenting a parent chain as though it were a certificate chain. Parentage is a point-in-time operating-system observation. A signed parent may launch an unsigned child. An expected parent may be a wrapper that execs something else. The approval card should make clear who is requesting the session now, then provide parent information as context.
This distinction also changes how revocation works. Revoke a session identifier, not a PID. When a person revokes the session, mark the immutable session record revoked and reject future action attempts that refer to it. If the original process still runs, it loses access. If it has exited and its PID has been reused, the revocation remains correct because it never depended on controlling that numeric PID.
One approval should mean one observed run. If users want a broader trust decision, build a separate explicit feature with a clear scope, such as a signed code requirement plus a stated duration. Do not smuggle that broader scope into a per-session approval because the implementation happened to have a convenient PID cache.
Test the failure instead of trusting normal process behavior
PID reuse bugs hide because ordinary manual testing does not generate enough turnover. A test suite should force your code to prove that it rejects a replacement process that has inherited a number, and that it rejects an exec that changes the code behind a surviving process.
You do not need to wait for macOS to reuse a specific PID naturally. Put the process-inspection layer behind an interface, then feed it controlled snapshots. A good test has an approved record and a later live observation with the same PID but a distinct start time:
captured: pid=4812 start=1784738598.482911 signer=team-A
observed: pid=4812 start=1784744029.031882 signer=team-B
expected: deny with process_start_time_mismatch
Add a second case where PID and start time match but the signing requirement fails. Add another where the child remains alive but exec has changed its executable facts. These tests prove that your authorization code joins the right fields, rather than merely testing that process inspection returns data.
Test the audit view separately. Seed an old session with a parent PID, simulate that parent exiting, then provide a later unrelated process with the same PID. The rendered historical session must keep the original captured parent fields. It must not replace them with a name obtained from the current process table.
Finally, test cancellation and inspection failures. A process can vanish in the short interval between accepting an IPC request and querying its details. Your code should record a clear denial reason and require the caller to establish a new session. Systems that turn these failures into best-effort matches create the exact ambiguity the identity model was built to remove.
The useful invariant is simple and strict
Every privileged agent action must refer to a session bound to one captured process lifetime. A matching live request must prove that it comes from that same lifetime, and its executable must still satisfy the code identity you approved. A reused PID fails the first test. A different binary behind an unchanged PID fails the second.
That invariant keeps process facts in their proper roles. Start data distinguishes lifetimes. Signer data identifies validated code. Parent details provide provenance. The session ID carries the human decision. None of those fields can do the other fields' job.
Sallyport can make that decision visible by leading session authorization with the process's code-signing authority and retaining separate records for runs and calls. The implementation should still refuse to let a convenient number, however familiar it looks in a log, stand in for the process that earned the approval.
The next time you see a table keyed by pid, ask what happens after the process exits. If the answer is "we look it up again," the table is not a session identity store. Fix that before an innocuous process lifecycle event turns into an authorization error.
FAQ
Why is a process ID not a unique process identity?
A PID is a slot in the operating system's process table, not a permanent identity. Once one process exits, macOS can assign the same number to a later process. If your record says only "PID 8421," it can point to the wrong process after enough churn.
What should I store with a PID to identify an agent process safely?
Use the PID with the process start time at minimum. For an agent control system, also record the user ID, the executable path captured at connection time, signing information, and a parent-process snapshot. Each field catches a different way a bare PID can mislead you.
Is a PID plus process start time enough for agent authorization?
No. A start time distinguishes two lifetimes that happened to receive the same PID, but it does not tell you whether you trust the executable. Pair it with validated signing information and keep the parent process as supporting provenance.
Can I trust a macOS signing identifier by itself?
A code-signing identifier names code within a signing scope, but it is not proof by itself that the process is the one you intended to approve. A sound identity check includes the signer or team context and validates the code requirement. For forensic records, keep the code directory hash as well when your platform exposes it.
How should I record a process parent without PID reuse errors?
Capture the parent when the child connects or when you observe the exec event. Do not look up the parent PID later and assume that the result is historical truth. Parent PIDs can be reused just like child PIDs.
How do I prevent PID reuse from approving the wrong agent?
Treat process identity as an authorization input, not a display label. Recheck the live process immediately before granting a session, compare its start time and signing facts to the captured record, and deny the request when the process has exited or changed.
Does Endpoint Security solve process attribution on macOS?
It can be useful, but it does not replace a lifecycle-aware identity record. Endpoint Security exposes process start time, audit data, code-signing fields, and parent audit data in its process structures. It also requires the right entitlement and operational work that many desktop apps do not need.
What is the difference between a cdhash and a process identity?
No. A code directory hash identifies signed code content, while a process identity identifies one running instance of that code. Ten concurrent processes can have the same hash, and one PID can refer to several different processes over time.
How should an audit log model agent sessions and process exits?
Keep immutable events, not one mutable session row that gets overwritten. Store the captured identity at session approval, append call records that refer to that session identity, and record revocation or exit as later events. That leaves reviewers with a sequence they can inspect instead of a current-state guess.
Can exec change an approved agent process without changing its PID?
Forking can preserve a PID while exec replaces the program image in that process. That means a PID and even a parent relationship can remain the same while the executable and signer change. Check the identity at the point where the agent actually connects and again before a long-lived authorization is used.