Agent executable identity after a symlink swap
Test agent executable identity against symlink swaps on macOS, bind approvals to running code, and keep path aliases out of trust decisions.

A filename is a locator, not an identity. That distinction looks pedantic until an agent receives approval while running as ~/bin/agent, somebody swaps that path to another program, and the gateway decides that the next caller is trusted because the label still looks familiar.
Symlinks make the mistake easy to reproduce, but they are not the root cause. Any mutable indirection can create it: a shell wrapper, a PATH entry, a copied binary, a development checkout, or a launcher that resolves a path on every request. A gateway that approves a pathname has approved an object that an unprivileged user can often replace.
On macOS, the useful unit is the running process and its code identity. Apple Code Signing Services can describe and validate code associated with a process. A path can appear in the record for diagnosis, but it cannot carry the authorization decision.
A symlink is a name service, not the program
A symbolic link stores a string that the kernel resolves when a program opens or executes it. Replacing that string changes what a future exec call finds. It does not rewrite the executable image of a process that has already started.
That detail gives people false comfort. They test a symlink swap, see the original process continue to work, and conclude that the attack is harmless. The test has only shown normal process behavior. It has not tested the decision that matters: whether the gateway recognizes a later process from the alias as the process it approved earlier.
Consider a coding agent launched through this path:
~/bin/build-agent -> ~/work/approved-agent
The gateway receives the first request, sees ~/bin/build-agent, and asks the operator for permission. An attacker then changes the link:
~/bin/build-agent -> ~/Downloads/replacement-agent
If the approved process keeps making requests, the gateway may correctly continue that session until the process exits. That is not the failure. The failure arrives when another process starts through the same name and the gateway says, "I approved ~/bin/build-agent already."
The second process is a new security subject. It needs a new inspection and, unless it matches an intentionally defined update rule, a new approval.
The same bug appears without symlinks. A gateway that runs codesign against the path supplied by a client can inspect one file while accepting a request from another process. The client can race the check by changing the path after the inspection, or it can simply point the gateway at a stable alias that refers to changing targets. A nice-looking path in an approval card does not repair that design.
Apple treats code requirements as identity constraints rather than filenames. Its documentation says a designated requirement is the criteria used to decide that code is the same code seen before, normally derived from the signing authority and embedded identifier when the developer has not declared one. That is the right direction, with an important qualification: a code requirement describes code identity, not a live process session.
The running process is the object that earned approval
A gateway should make its authorization decision against a live process reference acquired from the operating system. It should then keep that decision attached to the process run, not to a client claimed path, an executable name, or a reusable environment variable.
On macOS, a good inspection has two jobs:
- Obtain signing information for the actual caller process.
- Validate that process before using the identity information as authorization evidence.
These are separate jobs. SecCodeCopyDesignatedRequirement can return a designated requirement for signed code, but Apple explicitly notes that the call does not validate the signature. Code modified after signing or signed incorrectly can still produce incomplete or misleading information. A gateway must perform validity checking as well.
The process record should include enough evidence to explain the decision later:
- process identifier or, better, an OS-issued process credential that resists PID reuse
- executable path observed at inspection time, retained as context only
- signing identifier and team identifier where available
- signing authority and certificate chain where applicable
- designated requirement
- cdhash or the set of supported cdhash values
- signature validation result and time of inspection
Do not mistake this list for a policy language. The gateway needs a narrow answer: which running process is asking, and did a human approve this run? A few identity facts let it answer that question. A pile of path rules does not.
The awkward part is update handling. A designated requirement often stays stable across legitimate versions. That is why macOS uses it for continuity when a user authorizes an app to access a protected service. Apple Technical Note TN3127 gives the familiar example of an app update returning to use the microphone: macOS compares the new version against the recorded designated requirement.
That behavior is sensible for a durable operating system permission. It is too broad if you silently reuse a past human approval for a new autonomous agent run. A gateway can display the signing authority to help an operator recognize the program, while still asking again when a new process starts. The signer establishes provenance. The process boundary establishes the lifetime of consent.
Static path checks leave a race you cannot explain away
A static check answers a static question: "What signature does this file have at this path right now?" It cannot, by itself, answer: "What code issued this request?"
That gap matters even when the static check is technically correct. Suppose a gateway receives a request that says its executable path is /Users/dev/bin/agent. It runs:
codesign --verify --strict --verbose=2 /Users/dev/bin/agent
codesign -d -r- /Users/dev/bin/agent
Both commands may report a valid signature and a designated requirement. The gateway then stores the pathname as the approved identity. Between the check and a later request, an attacker can repoint agent at a different file. On the next request, the gateway may run those commands again and obtain facts about the replacement. Neither command proves a connection to the process that made the request.
There is a second race that teams miss. A helper may inspect a path before spawning an agent, then later receive a connection from a child process. The path check describes the parent file at spawn time. The connection describes a process at request time. If the helper does not bind those two events using an operating system credential, it has created an assumption and called it provenance.
Apple's code-signing APIs expose separate categories of information for this reason. kSecCSSigningInformation requests certificate and CMS data, kSecCSRequirementInformation requests requirements, and kSecCSDynamicInformation requests dynamic validity information for running code. The API surface does not turn those pieces into a complete gateway design, but it does make the distinction plain: static signature inspection and running-code state are different inputs.
A filename still belongs in the approval display. Operators need to see that a request came from a checkout in ~/work/demo rather than an installed tool in /Applications. Treat that as user interface context. The authorization record must remain tied to the caller that the operating system identified.
Reproduce the swap without touching a real agent
You can demonstrate the pathname problem with two small binaries in a temporary directory. This test does not require a production token, an SSH key, or a modified application bundle.
Create a workspace and compile two programs that print different markers. The code stays intentionally dull because the behavior under test is the replacement, not the program logic.
work="$(mktemp -d /tmp/agent-identity.XXXXXX)"
cd "$work"
cat > approved.c <<'EOF'
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("approved process pid=%d\n", getpid());
fflush(stdout);
sleep(60);
return 0;
}
EOF
cat > replacement.c <<'EOF'
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("replacement process pid=%d\n", getpid());
fflush(stdout);
sleep(60);
return 0;
}
EOF
clang approved.c -o approved-agent
clang replacement.c -o replacement-agent
codesign --force --sign - --identifier com.example.approved approved-agent
codesign --force --sign - --identifier com.example.replacement replacement-agent
ln -s "$work/approved-agent" agent
Ad hoc signing is enough for a local mechanics test, but it has no certificate chain. Apple documents that ad hoc signed code has no certificates and empty CMS data. Do not use an ad hoc signature to simulate a distribution signer or to decide what your production gateway accepts.
Record what the alias resolves to, inspect its signature, and start the first process:
printf 'alias before: %s\n' "$(readlink agent)"
codesign -d -r- agent 2>&1 | sed -n '1,8p'
./agent &
first_pid=$!
printf 'first pid: %s\n' "$first_pid"
ps -p "$first_pid" -o pid=,comm=,args=
The output shape should resemble this:
alias before: /tmp/agent-identity.xxxxxx/approved-agent
Executable=/tmp/agent-identity.xxxxxx/agent
designated => identifier "com.example.approved"
approved process pid=48291
first pid: 48291
48291 ... ./agent
Now replace the link while the first process is asleep:
rm agent
ln -s "$work/replacement-agent" agent
printf 'alias after: %s\n' "$(readlink agent)"
codesign -d -r- agent 2>&1 | sed -n '1,8p'
ps -p "$first_pid" -o pid=,comm=,args=
You should now see com.example.replacement when inspecting agent, while first_pid remains alive. Start the alias a second time:
./agent &
second_pid=$!
printf 'second pid: %s\n' "$second_pid"
wait "$first_pid" "$second_pid"
The second process prints replacement process. The two PIDs ran different program images even though both were invoked as ./agent.
This is the result that should change your gateway test plan. A test that only asks whether the old PID kept running has proven almost nothing. The useful test asks whether the gateway treats the second PID as a fresh caller and shows its observed identity before it can use an approved action.
A useful approval test has three distinct runs
Do not settle for one happy-path approval and one symlink command. You need three runs because each run proves a different property.
First, launch the approved target through the alias and make an action request. The gateway should create a session record and show an approval that identifies the actual caller in terms an operator can judge. Record the session identifier, PID, observed path, and code-signing facts.
Second, change the alias but leave the first process alive. Have the first process make another request. If your session model approves one process run until it exits, the gateway may allow that request. The executable image did not change. Do not falsely label this expected result a vulnerability.
Third, launch a new process through the replaced alias and make the same request. The gateway must not inherit the first process's approval merely because the executable name, command line, working directory, or requested action matches. It should create a separate session and require the normal authorization path.
Use a result table while you run the test:
| Run | Alias target at launch | Expected result |
|---|---|---|
| First process | approved-agent | New approval, then allowed for that run |
| First process after swap | replacement-agent on disk, but old image remains in memory | Existing session behavior continues until exit |
| Second process | replacement-agent | New approval or denial, never inherited approval |
There is one more case worth testing. Swap the alias back to the original binary, then start a third process. A gateway should still create a new session. Same code identity does not mean same process. If your product deliberately offers a persistent trust relationship for a signed publisher, make that a separate, explicit operator decision. Do not smuggle it into a per-process approval feature.
Sallyport's per-session authorization is on by default, and its approval card leads with the process's code-signing authority rather than a mutable filename. That gives the operator a stronger fact to assess when a new agent process asks to act.
Signer, designated requirement, and cdhash answer different questions
Teams often compress three distinct concepts into the phrase "signed by the same app." That shortcut produces either approval fatigue or permission that lasts too long.
A signing authority answers who signed the code. For distributed software, that may include a certificate chain and a team identifier. It helps an operator distinguish a known vendor from a random executable. It does not name an exact build.
A designated requirement answers whether macOS should consider code to be the same identity across updates. Apple says that all signed code has a designated requirement, either explicit or synthesized. The default normally incorporates the signing authority and embedded identifier. That makes it suitable for continuity, but it may accept later versions that you have not personally inspected.
A cdhash identifies a particular signed CodeDirectory. It is close to a build fingerprint for authorization purposes. It is excellent audit evidence because it lets an investigator distinguish two versions that share a signer and identifier. It is usually a bad permanent approval rule for developer tools, because routine updates will change it.
Use the evidence according to the decision:
- Use the live process credential to bind a session to a caller.
- Show signing authority and identifier in the approval interface so the operator can recognize the source.
- Record the designated requirement and cdhash so later investigation can distinguish publisher continuity from the exact build.
- Ask again for a new agent process even when the signer and designated requirement match.
The last point is deliberately stricter than macOS permissions. An agent gateway can issue HTTP requests or SSH commands with credentials that never enter the agent process. That is an action boundary, not a one-time microphone prompt. Reusing a past click across an unrelated future process weakens the human control that justified the gateway in the first place.
Do not turn a file watcher into authorization
A tempting response is to watch the agent path and revoke permission when the file changes. This is popular because it looks simple: save the original inode, subscribe to filesystem events, and invalidate the approval after a write or rename.
It is the wrong foundation.
Filesystem events are useful telemetry, not proof of the caller's identity. Paths can have multiple names. A binary can be copied. A process can be launched from an unlinked file. Event delivery can be delayed or coalesced. An attacker does not need to win a dramatic race if your design already authorizes the path instead of the process.
An inode comparison does not repair the model either. It may help catch one replacement in one directory, but it says nothing useful about a process started from another hard link or copied target. It also creates brittle behavior on ordinary development workflows where tools rebuild, rename, and replace files constantly.
Treat file observations as a reason to add useful audit context. If the current path points to a different target than it did at approval time, record that fact. If your gateway sees a new caller process, inspect that caller and apply the normal authorization flow. The process boundary handles the security decision without trusting the watcher to act like a reference monitor.
This rule also prevents a subtler mistake: authorizing a wrapper because its signature looks familiar while ignoring what it launches. A signed wrapper can execute an unsigned child, load a local script, or select a target based on its environment. The gateway must identify the process that actually connects and requests the privileged action. If the wrapper is the caller, it is the object you approve. If its child is the caller, inspect the child.
Bind approval to an OS credential and fail closed on ambiguity
A practical implementation needs an OS-provided handle for the requester. On macOS, that commonly means obtaining a guest code object for the calling process through Code Signing Services, using process attributes supplied by the trusted connection context rather than values copied from agent input.
The safe sequence looks like this:
- Accept a connection and obtain the caller's OS identity from the connection mechanism.
- Resolve that identity to a running code object.
- Perform code validity checking before reading identity data for authorization.
- Read signing authority, identifier, designated requirement, and cdhash from that running code object.
- Create a session record bound to the caller credential and require approval if no approved session exists.
- Recheck that the caller credential still refers to the same live process before each action, then destroy the session when the process exits or the operator revokes it.
The details of step one depend on the transport. A local Unix socket, an XPC connection, and a child process pipe expose different credentials. The dangerous fallback is the same in every case: accepting a PID, path, bundle identifier, or signature summary that the agent sends in its request payload. The agent controls that payload. It is evidence of nothing.
PID reuse deserves special attention. A bare PID is only unique while the process exists. After exit, the operating system can assign the same number to another process. Store a richer credential if the API gives you one. If your transport cannot provide a durable caller binding, shorten the session to the connection lifetime and reauthorize after reconnect. Failing closed is less convenient than accepting an ambiguous caller, but ambiguity is exactly where path swap bugs live.
Keep approval prompts honest. If the gateway sees an ad hoc signature, say so. If no valid signature exists, say that. If the program has a familiar display name but a new signing authority, do not tuck the authority behind a disclosure triangle. The first line should tell the operator who signed the process and what action it wants to perform.
The audit trail must preserve the decision, not just the request
A log entry that says POST /deploy succeeded cannot explain a symlink swap incident. It tells you what happened, but not why the gateway let that caller do it.
For every approved session, store a snapshot of the identity evidence observed at approval time. For every action, store the session reference and the result. The action record does not need to copy certificate data repeatedly, but it must lead back to the exact approval record that contained it.
A compact record can look like this:
{
"session": "6F2A...",
"caller": {
"process": "OS-issued caller credential",
"path_observed": "/private/tmp/demo/agent",
"signing_identifier": "com.example.approved",
"designated_requirement": "identifier com.example.approved",
"cdhash": "<observed digest>",
"validity": "valid"
},
"approval": "granted",
"action": "HTTP POST /deploy",
"result": "success"
}
The path remains useful. It can tell you that a process launched from a temporary directory or a shell alias. It must not be sufficient to join a later request to this session.
Sallyport keeps both a Sessions journal for agent runs and an Activity journal for individual calls, projected from one encrypted, hash-chained audit log; its offline sp audit verify command checks the chain without requiring the vault key. That design makes a path swap test easier to review because the approval event and the later action are separate facts rather than one vague success message.
Run the symlink exercise before you write an approval rule that you cannot later describe. If a newly launched replacement can use the first process's approval, stop treating the executable name as a convenience field. It has escaped into the trust decision.
FAQ
Does a code signature make an executable path safe to trust?
A signed executable can still be reached through a symlink, hard link, copied path, or launcher script. The name only tells you where a process was found. It does not prove what code is running or who signed it.
Can replacing a symlink change a process that is already running?
No. A running process keeps executing the program image loaded by exec, while a symlink replacement affects later path resolution. The security failure begins when a gateway looks up the mutable path again and treats that lookup as evidence about the already approved process.
How should a macOS app identify an agent process?
Use the running process as the object of inspection, not the pathname supplied by the client. On macOS, collect process signing information through Code Signing Services and validate it before granting a session.
What is a designated requirement on macOS?
A designated requirement identifies a family of code that macOS considers the same publisher and application identity across legitimate updates. It does not uniquely identify one exact build, so use it for continuity rather than as the only record of what ran.
Should I approve an agent by cdhash?
A cdhash identifies a particular signed code directory and is useful evidence for a specific build. It is stricter than a signer or designated requirement, but pinning it indefinitely will make normal updates fail until someone intentionally approves the new build.
Should an agent approval survive a restart?
No. Approval should belong to a process run, not to a filename and not to a permanent developer identity. A new process should create a new approval event, even if it comes from the same signed application.
Why is path-based approval dangerous for AI agents?
The dangerous pattern is approving a helper once and treating every future process launched from that path as approved. Attackers like it because they can replace a symlink, a wrapper, or a binary after the initial decision without changing the familiar command name.
What should an agent action audit log record?
An audit record should include the process identifier or audit token, the observed executable path as context, signing authority, identifier, designated requirement, cdhash, validation result, and the session start time. The path is useful for diagnosis, but it must not be the trust anchor.
What should happen when the replacement executable is unsigned or differently signed?
An unsigned replacement should fail if your approval rule requires a valid signature. A replacement signed by a different authority should require a new approval, while a legitimate update signed under the identity you deliberately allow should follow the update policy you chose.
Is it safe to test a symlink swap on my work Mac?
Do the swap in a temporary directory with binaries you compiled yourself, and point the alias at those binaries. Do not replace files inside an application bundle you did not create, disable platform protections, or test against a production credential.