Agent approval on macOS must distinguish every build
Agent approval on macOS needs to distinguish stable, beta, package-manager, and local builds before one trusted process covers another.

Approving an AI agent by a friendly command name is how you approve the wrong executable. On one Mac, it is easy to accumulate a vendor release, a beta, a package-manager copy, and a build from source that all answer to the same name. If your approval boundary cannot tell those apart, a decision made for a reviewed build can silently apply to another one.
The fix is not a longer allowlist. You need to decide which properties identify an agent run in your environment, prove that each installed copy has the expected properties, and then test the approval behavior with both copies present. Paths, signatures, designated requirements, and hashes answer different questions. Confusing them is where teams get hurt.
One command name can point to several executables
A shell command is a lookup, not an identity. When you type claude, agent, or a wrapper name, your shell may resolve an alias, function, shim, symlink, package-manager directory, or a file earlier in PATH than the copy you had in mind.
Start in the terminal, editor, launch service, or automation runner that actually starts the agent. Do not inspect a convenient interactive shell and assume the result transfers. Login shells, GUI applications, and CI runners often receive different environment variables.
Run this first:
type -a agent-name
command -v agent-name
A useful result might look like this:
agent-name is /Users/me/bin/agent-name
agent-name is /opt/homebrew/bin/agent-name
agent-name is /usr/local/bin/agent-name
/Users/me/bin/agent-name
That output says the first path wins in this shell. It does not say whether /Users/me/bin/agent-name is a direct binary, a symlink, a script that launches another binary, or a wrapper that changes environment variables before it hands control over.
Resolve the file before you inspect it:
BIN="$(command -v agent-name)"
python3 - <<'PY' "$BIN"
import os, sys
print(os.path.realpath(sys.argv[1]))
PY
file "$BIN"
If file reports a shell script, read it. If it reports a symlink, inspect the destination. If it reports a universal Mach-O executable, inspect that executable. A wrapper can make an approval screen look correct while launching a different child process later.
Do not stop after finding the current winner. Inventory every result from type -a, plus copies in the vendor's Applications folder, your Downloads folder, the source checkout, and package-manager cellars. The copy that wins today can lose tomorrow after a package-manager upgrade or a small PATH edit.
A path is evidence of location, not proof of identity
Teams often start with a path rule because paths are easy to read. /Applications/Agent.app feels deliberate. /Users/me/dev/agent/bin/agent feels experimental. Those are useful clues, but a path alone cannot establish what code sits there now.
A package manager can replace the file at a stable symlink. A direct installer can overwrite an app bundle in place. A local build can use the same output path every time. An attacker with sufficient write access can replace a file at an approved path. The path only tells you where the loader found it.
Use four separate fields in your inventory:
| Field | What it tells you | What it cannot tell you |
|---|---|---|
| Resolved path | Where this launch found the file | Who produced it or whether it changed |
| Signing information | Which signing identity attached to the code | Whether another build from that signer has identical behavior |
| Designated requirement | The continuity rule macOS associates with the code | Whether the rule is narrow enough for your approval purpose |
| SHA-256 hash | The exact bytes you inspected | Whether a future update should inherit trust |
This distinction matters because each field changes at a different rate. A vendor update may preserve the path and designated requirement while changing the hash. A beta may keep the signing authority but use a different bundle identifier. A local build may have the same source revision as a release but carry an ad hoc signature, or none at all.
Apple's Technical Note TN2206 explains the intended role of the designated requirement: it should match legitimate updates of a program and exclude unrelated code. That makes it a continuity mechanism. It does not make it a universal answer to "did I mean this exact binary?" Apple also notes that the default requirement is synthesized from the signing setup, so its width depends on how the developer signed the build.
Approval systems need the same separation. A decision to trust a publisher's next compatible release differs from a decision to trust one exact release artifact. Treating those as the same decision makes it impossible to explain what the user approved.
Inspect the signature before you create an approval rule
For every candidate executable, capture its signature details and its hash. The following commands use only built-in macOS tooling plus shasum:
inspect_agent() {
target="$1"
echo "=== $target ==="
echo "Resolved path: $(python3 - <<'PY' "$target"
import os, sys
print(os.path.realpath(sys.argv[1]))
PY
)"
shasum -a 256 "$target"
codesign --display --verbose=4 "$target" 2>&1 \
| grep -E '^(Executable|Identifier|TeamIdentifier|Authority|CDHash)='
codesign --display -r- "$target" 2>&1 \
| sed -n '/designated/,$p'
codesign --verify --strict --verbose=2 "$target" 2>&1
}
inspect_agent "$(command -v agent-name)"
The shape of the output matters more than a particular vendor string:
=== /opt/homebrew/bin/agent-name ===
Resolved path: /opt/homebrew/Cellar/agent-name/2.4.1/bin/agent-name
3b1f... /opt/homebrew/bin/agent-name
Executable=/opt/homebrew/bin/agent-name
Identifier=com.example.agent
TeamIdentifier=AB12CDE345
Authority=Developer ID Application: Example, Inc. (AB12CDE345)
CDHash=9c1a...
designated => anchor apple generic and identifier "com.example.agent" and certificate leaf[subject.OU] = "AB12CDE345"
/opt/homebrew/bin/agent-name: valid on disk
/opt/homebrew/bin/agent-name: satisfies its Designated Requirement
Do not treat the displayed CDHash as a replacement for your SHA-256 record. The CodeDirectory hash is part of code-signing machinery and can vary with signature structure. shasum -a 256 gives you a familiar exact-file fingerprint for a test record. Capture both when you are investigating a collision.
For an app bundle, inspect the actual executable rather than only the bundle directory. Find it with:
APP="/Applications/Agent.app"
EXEC="$APP/Contents/MacOS/$(defaults read "$APP/Contents/Info" CFBundleExecutable)"
inspect_agent "$EXEC"
If the agent starts a helper, inspect the helper too. The executable that opens a terminal window is not always the process that makes HTTP calls or initiates SSH. An approval system should identify the process that requests the sensitive action, while your test should verify the whole launch chain.
Apple's newer TN3127 goes further than the older signing guide: it shows how default designated requirements differ across signing types and why separately distributed variants may not be mutually compatible. That is a warning against assumptions. Compare the actual requirement text from your installed files.
Stable, beta, package-manager, and local builds need a test matrix
Make the inventory concrete. Pick the copies that can plausibly exist on your Mac, give each one a short label, and record what you expect it to share or not share with the others.
| Label | Typical source | Expected signature state | Approval expectation |
|---|---|---|---|
| Stable | Vendor installer or app bundle | Release signing identity | Baseline candidate |
| Beta | Vendor beta channel | May share the release identity | Must be tested separately |
| Package manager | Formula, cask, npm-style shim, or similar | Depends on upstream packaging | Must resolve the launched target |
| Local | Source checkout or build output | Development, ad hoc, or unsigned | Separate review by default |
This table does not dictate the right answer. It prevents the lazy answer, which is assuming that a channel name tells you enough. A beta can be signed exactly like stable. A package-manager copy can be an unmodified vendor artifact, a repackaged artifact, or a script that downloads another executable. A local build can carry a valid development signature that looks more official than it deserves.
Create one row per installed copy in a text file that lives with your agent setup notes:
label: stable
launch path: /Applications/Agent.app/Contents/MacOS/agent-name
resolved path: /Applications/Agent.app/Contents/MacOS/agent-name
version: 2.4.1
identifier: com.example.agent
team or authority: AB12CDE345
sha256: 3b1f...
designated requirement: anchor apple generic and identifier "com.example.agent" ...
expected approval group: release
label: local
launch path: ~/src/agent/build/agent-name
resolved path: /Users/me/src/agent/build/agent-name
version: git revision recorded separately
identifier: ad hoc or absent
team or authority: none
sha256: 8e52...
designated requirement: unavailable or different
expected approval group: local only
Record the version, but do not let version number carry the decision. Version strings are application metadata. A file can claim a version string that does not match the release you thought you downloaded. The signature and hash give you independent facts.
The awkward case is two rows with the same identifier, team, and designated requirement but different hashes. That is not necessarily a defect. It means the publisher has made two builds that macOS may regard as instances of the same program. If you want approval to follow the publisher's release channel, that may be acceptable. If you need the approval to cover only a particular release artifact, it is too broad.
The wrong test approves each copy one at a time
Testing the stable build on Monday and the beta build on Tuesday proves almost nothing about cross-coverage. Each test can prompt because no active approval exists yet. You need both copies installed and a session from one still alive while the other asks for access.
Run this sequence in a disposable account or with credentials that cannot alter production systems:
- Put the stable, beta, package-manager, and local copies in place. Confirm their resolved paths and save the inspection output.
- Clear or revoke the existing agent session through the approval tool you use. Confirm that the next sensitive call will require a new decision.
- Start the stable copy and make a harmless call to a test endpoint. Approve only that run. Keep its process alive.
- While the stable process remains alive, start the beta copy and make the same harmless call. Watch whether it asks for approval, and inspect the process identity shown on the card.
- Repeat with the package-manager and local copies. Then reverse PATH order and repeat the package-manager test.
The result you want depends on the rule you chose. If stable and beta are meant to be separate, beta must prompt while stable remains approved. If they intentionally share an approval group, the card and audit trail need enough detail to make that grouping obvious to a reviewer.
Do not call a production API for this test. Use a test endpoint that returns a fixed response and leaves a visible, harmless marker in its own logs. For SSH, use a test account with a command restricted to printing identity information:
ssh [email protected] 'id; hostname; date -u +%FT%TZ'
The test has two observations: what the approval screen says and what the target system logs. Save the timestamp, executable hash, process ID, and returned marker. If a wrapper changed which executable actually ran, that discrepancy will show up sooner than it will in a post-incident discussion.
A common failure looks like this. A developer approves the stable app, then installs a beta that adds /Users/me/bin ahead of /Applications through a shell setup change. The command name remains unchanged. The beta makes a call without a fresh prompt because the authorization check recognizes a shared signing identity or a too-broad process grouping. Nobody notices because the original stable process is still running and the audit record only says agent-name. You prevent this failure by forcing the overlap test, not by reading version release notes.
Process identity and exact bytes answer different approval questions
An approval can reasonably attach to a signed process identity that remains stable across updates. It can also attach to exact bytes. Neither choice is universally right.
Use process identity when you intend to trust a maintained release line from a known signer. This avoids forcing a user to approve every patch release just because the executable hash changed. It also lets a fixed process identity survive a normal update path.
Use exact bytes when the build is experimental, locally produced, independently patched, or taken from a channel you do not want to merge with stable. A hash pin is especially useful for a short investigation or reproduction where you want a decision to expire when the file changes.
The dangerous middle ground is pretending that a signing identifier alone is an identity. Apple documents that a signing identifier can be claimed by multiple signers. Apple recommends pairing it with relevant validation and team constraints when checking code. In practical terms, Identifier=com.example.agent without the authority or team is a label someone else may reuse.
A designated requirement is generally stronger because it can combine the identifier with signing authority constraints. It can still be broader than your intent. A requirement that accepts any valid build signed by one team under one identifier may correctly span a stable update, a beta, and a vendor-generated local test build. That is good only if you actually want all three in the same approval group.
Write the group decision in plain language next to the inventory entry. For example:
Release group: accept future vendor-signed builds with the release identifier.
Beta group: separate, even when signed by the same vendor identity.
Local group: exact SHA-256 only; rebuild requires another approval.
That note forces the decision before the UI asks for a click. It also exposes whether your tooling can express the distinction. If it cannot, use a narrower operational boundary, such as per-call approval for the beta and local builds, until it can.
Per-session approval is not a substitute for per-call approval
Per-session approval answers "may this agent process run with access during this run?" Per-call approval answers "may this specific credential be used for this specific action now?" The first control limits which process gets a session. The second limits the consequence of that session.
Sallyport uses a fixed decision ladder: a locked vault denies every action, a new agent process normally needs a session approval, and selected credentials can require a fresh approval on every use. The approval card leads with the process's code-signing authority, which is useful evidence, but you should still run the overlap test when more than one build exists.
Use the more frequent check for credentials that can cause irreversible or externally visible changes. Production deployment credentials, destructive administrative APIs, and SSH access that can alter shared infrastructure should not inherit trust merely because an agent process was acceptable at the start of a long run.
Do not solve a binary collision by marking every credential for every-call approval forever. That turns a design problem into approval fatigue. People approve repeated, predictable prompts without reading them. Instead, separate the builds that should not share access, then reserve per-call approval for actions where a human must see the moment of use.
For an action gateway, test the same matrix at the action boundary. Start each binary, make one test HTTP request and one test SSH command if both channels are in use, then compare the session record and the individual-call record. The records should let you answer which executable initiated the request, which approval covered it, and whether the credential required another confirmation.
Package managers and shims hide the executable you need to inspect
Package managers often install stable front-door paths that point elsewhere. A command at /opt/homebrew/bin/agent-name may be a symlink into a versioned Cellar directory. Another tool may install a JavaScript, Python, or shell shim that chooses a runtime and then loads a package from a cache directory.
Follow the chain until you reach the process that makes the sensitive request. These commands help with common cases:
ls -l "$(command -v agent-name)"
readlink "$(command -v agent-name)" || true
head -n 40 "$(command -v agent-name)" 2>/dev/null || true
On macOS, readlink may show only one hop. The small Python resolver from the first section is more reliable for the final target. If the front-door file is a script, search for exec, runtime invocations, downloaded binary paths, and environment variables that select a release channel.
Do not assume a package-manager version is equivalent to the vendor version with the same number. The package may apply patches, repackage a binary, compile from source, or launch a different runtime. Treat the installed executable as the thing you approve, and retain the package receipt only as supporting context.
The same warning applies to IDE extensions and terminal integrations. A graphical launcher may bundle one copy while your shell uses another. Test each entry point that can start an agent. "It prompts correctly in Terminal" is not evidence about a background task started by an editor.
Local builds must advertise that they are local
A local build is useful precisely because it can differ from a release. You may have one patch, an unreviewed dependency update, a compiler change, a debug flag, or a generated file that never entered the vendor artifact. It should not quietly borrow the approval reputation of the release build.
First inspect its signing state:
LOCAL="$HOME/src/agent/build/agent-name"
codesign --display --verbose=4 "$LOCAL" 2>&1 | sed -n '1,25p'
codesign --verify --strict --verbose=2 "$LOCAL" 2>&1
shasum -a 256 "$LOCAL"
Unsigned output, an ad hoc signature, and a development signature are not interchangeable. An unsigned executable gives an approval layer less durable identity evidence. An ad hoc signature can make a file look signed without connecting it to a developer identity. A development signature identifies a development context, but it still does not make the file a release artifact.
The safest default is simple: place local binaries in a separate directory, give them a visibly different command name if you control the build, and require a new approval whenever the hash changes. If you cannot rename the command, make the resolved path and local-build status obvious in your runbook and test output.
Avoid the popular advice to re-sign every local build with the same certificate used for releases just to reduce prompts. It is popular because it makes development smoother. It is wrong when your approval model uses signing authority as a meaningful boundary. You have widened the release identity to include every machine and script that can access that certificate. Keep release signing material out of casual local builds unless your release process can maintain that claim.
Audit records must let you reconstruct the decision later
An approval record that says only "agent approved" is weak evidence. Six weeks later, you cannot tell whether the approved program came from the stable installer, the beta folder, a package-manager symlink, or a local checkout.
For each test and meaningful operational change, retain these facts:
- the launching path and final resolved path
- signing identifier, authority or team, and designated requirement text
- SHA-256 hash and application version
- process ID, start time, and session outcome
- the action target and the individual-call outcome
Sallyport keeps agent runs in its Sessions journal and individual actions in its Activity journal, both projected from an encrypted hash-chained audit log. Its offline sp audit verify check can establish whether that encrypted log chain still verifies, but log integrity does not fill in identity fields you never captured. Make the executable evidence part of the event context while you can still inspect the machine.
Run an audit verification after you change the build matrix, revoke a session, and repeat the overlap test. You want to confirm two things: the system recorded the separate runs you expected, and the record trail still verifies when read independently. A clean chain proves continuity of the record, not that your original grouping decision was wise.
Turn this into a regression test, not a one-time cleanup
Multiple copies return. Someone installs a beta to test a fix. A package manager upgrades overnight. A teammate shares a local build. The stable app updates in place. If you only test after a scare, you will discover the collision when the agent already has access.
Keep a short test script that writes a timestamped report for every candidate path. Run it after installation changes, before you grant a new high-impact credential, and whenever you modify shell startup files or editor agent settings.
#!/bin/zsh
set -eu
for candidate in \
"/Applications/Agent.app/Contents/MacOS/agent-name" \
"/opt/homebrew/bin/agent-name" \
"$HOME/src/agent/build/agent-name"; do
[[ -e "$candidate" ]] || continue
echo "### $candidate"
echo "resolved: $(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$candidate")"
shasum -a 256 "$candidate"
codesign --display --verbose=4 "$candidate" 2>&1 \
| grep -E '^(Identifier|TeamIdentifier|Authority|CDHash)=' || true
codesign --display -r- "$candidate" 2>&1 \
| grep 'designated' || true
echo
done
Diff the report against the last reviewed copy. A changed hash is expected after an update. A changed signing authority, identifier, or designated requirement deserves an explicit decision before you treat it as a routine update. A new path that appears before the release path in type -a deserves the same attention.
The practical standard is plain: an approval should apply to the executable group you intended, and your evidence should show why that group includes one build and excludes another. Put stable, beta, package-manager, and local copies on the same Mac, keep one approved process running, and force each other copy to ask. If the result surprises you, the approval boundary is too vague.
FAQ
Can I trust an agent approval that only shows the process name?
No. A process name tells you almost nothing useful about who built the executable or whether it matches the program you intended to approve. Treat the name as a label for a human, then inspect the path, signature, designated requirement, and executable hash.
Should stable and beta versions of an agent share approval on macOS?
Usually no. Stable and beta builds can share a bundle identifier, command name, and signing authority, especially when the same publisher ships both. Test them as separate candidates before you decide one approval should cover the other.
Does a package manager install count as a separate agent identity?
A Homebrew installation is not automatically safer or more distinct than a direct download. The relevant question is which executable your shell launches and what code-signing identity and hash it has after installation.
Should I approve a locally built agent binary?
A local build should normally trigger its own review unless you intentionally trust its signing identity and build process. An ad hoc signature, a development signature, or an unsigned executable gives you a very different assurance level from a released build.
How do I find every copy of an agent command on my Mac?
Start with type -a agent-name, then inspect every returned path with codesign and shasum. Do this in the exact terminal environment that starts the agent, because PATH order and shell functions can change the result.
What is a designated requirement in macOS code signing?
A designated requirement describes the conditions macOS uses to recognize signed code as the same program across updates. It commonly includes a signing identifier and signing authority, so it is useful for continuity but may be too broad when you want to separate release channels.
Is the code-signing authority enough to identify an executable?
A code-signing authority identifies who signed the executable. It does not prove that two files are byte-for-byte identical, so pair it with the executable hash when you need to distinguish builds from the same signer.
How can I test whether one approval covers the wrong executable?
The practical test is simple: leave one approved copy running, start the other copy, and see whether the approval boundary behaves as you intended. Repeat after revoking the first session and after changing PATH order.
When should I require approval for every agent call?
Use per-call approval for credentials that can change production data, expose customer records, or reach systems outside your normal development environment. Session approval is better suited to a reviewed agent run whose permitted actions still have a limited blast radius.
What should I record for each approved agent build?
Keep a small inventory with the executable path, installation source, version, signing identifier, team or authority, designated requirement, and SHA-256 hash. Refresh it whenever you install a beta, update a package manager, or rebuild from source.