Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/sandbox/macos-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface MacOSSandboxParams {
allowGitConfig?: boolean
enableWeakerNetworkIsolation?: boolean
allowClipboard?: boolean
allowNotifications?: boolean
binShell?: string
}

Expand Down Expand Up @@ -332,6 +333,7 @@ function generateSandboxProfile({
allowGitConfig = false,
enableWeakerNetworkIsolation = false,
allowClipboard = false,
allowNotifications = false,
logTag,
}: {
readConfig: FsReadRestrictionConfig | undefined
Expand All @@ -346,6 +348,7 @@ function generateSandboxProfile({
allowGitConfig?: boolean
enableWeakerNetworkIsolation?: boolean
allowClipboard?: boolean
allowNotifications?: boolean
logTag: string
}): string {
const profile: string[] = [
Expand All @@ -367,6 +370,8 @@ function generateSandboxProfile({
'',
'; Mach IPC - specific services only (no wildcard)',
'(allow mach-lookup',
' (global-name "com.apple.audio.AudioComponentRegistrar")',
' (global-name "com.apple.audio.audiohald")',
' (global-name "com.apple.audio.systemsoundserver")',
' (global-name "com.apple.distributed_notifications@Uv3")',
' (global-name "com.apple.FontObjectsServer")',
Expand Down Expand Up @@ -499,6 +504,21 @@ function generateSandboxProfile({
)
}

// Notification access - SCOPED TO TERMINAL-NOTIFIER ONLY using signing-identifier
if (allowNotifications) {
profile.push(
'; Notification access - scoped to terminal-notifier via signing-identifier',
'; Minimal set: only core notification pipeline services',
'(with-filter (signing-identifier "terminal-notifier")',

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a way we can add SOME, BUT NOT COMPLETE safety around limiting restrictions to certain processes. With processes that are better signed than terminal-notifier, we can check the signing against a team id. As I mention in the pr description, there is a hole in this security posture, but I deem it acceptable.

' (allow mach-lookup',
' (global-name "com.apple.usernoted.client")',
' (global-name "com.apple.windowserver.active")',
' )',
')',
'',
)
Comment on lines +507 to +519

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

macOS sandbox seatbelt signing-identifier security spoofing ad-hoc signing

💡 Result:

On macOS, Seatbelt/App Sandbox policies can match a process by its code signing identifier via the SBPL predicate (signing-identifier "...") (Apple uses this in platform sandbox policy rules/exceptions). [1]

Why this is spoofable (especially with ad-hoc signing)

  • The code signing identifier is just a string embedded in the code signature; requirement language can test identifier "com.example.app" for exact equality. [3]
  • When signing, you can explicitly set that identifier with codesign -i <identifier>. If you don’t, it’s derived from the bundle’s Info.plist (typically CFBundleIdentifier) or the filename. [2]
  • Ad-hoc signing (codesign -s - ...) does not bind code to a real developer identity/certificate chain; many security tools treat ad-hoc as effectively “unsigned” because it provides no publisher authenticity. [5] (In Apple’s requirement language terms, ad-hoc signatures have no certificates, so certificate-based constraints can’t validate an identity.) [3]

Consequence: If a sandbox/Seatbelt rule gates privilege using only signing-identifier, an attacker can often re-sign their own binary (including ad-hoc) with the same identifier string and satisfy that check. (This is the core “signing-identifier spoofing” issue.)

What does provide a stronger boundary

To avoid spoofing, policies/authorization should not rely on identifier alone; they should also bind to something that can’t be forged by ad-hoc signing, such as:

  • a certificate/anchor-based requirement (Apple-issued chain / Team identity) rather than just identifier, leveraging requirement language’s certificate constraints (which fail for ad-hoc). [3]
  • restricted entitlements that must be authorized (via provisioning) specifically to prevent other developers from impersonating an app for sensitive resources (Apple explicitly calls out keychain groups as an example). [6]
  • other non-forgeable attributes sometimes used in sandbox policy, like platform-binary status, etc. (visible in Apple’s own policy patterns). [1]

Related: “same identifier” makes components indistinguishable

Apple documentation notes that giving multiple components the same code signing identifier (via matching CFBundleIdentifier or codesign -i) can make them validate “as the same program” for purposes like keychain access—useful when intentional, dangerous if assumed to imply a verified developer identity. [4]


🌐 Web query:

macOS seatbelt team-identifier predicate documentation

💡 Result:

Apple doesn’t publish official/public documentation for SBPL (Seatbelt) filter predicates like team-identifier. The public docs (sandbox(7), sandbox-exec(1)) describe the existence of the sandbox and how profiles are applied, but not the full predicate vocabulary (Apple treats most of the low-level sandbox(7) API surface as private/undocumented). [1], [2]

What’s available instead:

  • Reverse-engineered predicate lists / references: Mark Rowe’s deep-dive explains SBPL evaluation and links to extracted lists of supported filter predicates (for macOS 15.x), including code-identity related filters like signing-identifier (but Apple still doesn’t document these). [2]
  • System-shipped profiles to grep for real usage: macOS ships many .sb profiles in /usr/share/sandbox, which is often the most practical “documentation” source. [3]
  • How to get the Team ID for any binary (for use in identity matching approaches): codesign -dv <path> shows TeamIdentifier=…. [4], [5]

If you share what exact SBPL snippet you’re looking at (e.g., where you saw team-identifier used), I can point to the closest documented equivalent and/or how that predicate is typically replaced (often via other identity predicates like signing-identifier). [2]

Sources
[1] Chromium: “The Mac Sandbox” (Seatbelt / sandbox(7) overview) (chromium.googlesource.com)
[2] Mark Rowe: “Sandboxing on macOS” (SBPL, private APIs, predicate discussion + links to extracted predicate lists) (bdash.net.nz)
[3] s7ephen repo notes using /usr/share/sandbox as reference profiles (github.com)
[4] Addigy: finding TeamIdentifier= via codesign -dv (support.addigy.com)
[5] Jamf: locating Identifier and TeamID via codesign -dv --verbose=4 (support.jamf.com)

Citations:


🌐 Web query:

macOS sandbox code signing security signing-identifier vs team-identifier strength

💡 Result:

  • “team-identifier” (aka Team ID, 10‑char) is an identity scope for the developer/team extracted from the Apple-issued signing certificate and also appears in entitlements as com.apple.developer.team-identifier. Apple uses it as a coarse trust boundary for “same-vendor” checks (for example, which third‑party libraries an app can load, and which apps can participate in certain cross-app capabilities when their Team IDs match). [1][2]

  • “signing-identifier” in this context maps to the code signature’s Identifier (typically your bundle identifier) that you can match in a code requirement via identifier "com.example.app". This is an app-specific identity check (exact match only). [3]

Strength (security binding)

From strongest (most specific) to weakest (most permissive):

  1. Team ID + Identifier (recommended when you need to trust one specific app)

    • Typical pattern: anchor apple generic and identifier "…" and certificate leaf[subject.OU] = "TEAMID" (OU is the Team ID). This pins both the vendor and the exact app identity. [4][3]
  2. Identifier only

    • Pins a specific bundle ID, but (depending on how the requirement is constructed) may be less explicit about which Apple-issued developer identity must have signed it (you generally still want an Apple anchor / cert constraints). [3]
  3. Team ID only

    • Trusts any code signed by that Team ID (all apps/extensions from that developer/team). This is intentionally broader, and therefore weaker, but useful for admin allowlisting at “vendor” granularity. [5][2]

Practical guidance

  • Use Team ID matching when the policy is “trust this developer/vendor broadly” (common in MDM allowlists). [5]
  • Use Identifier + Team ID when the policy is “trust only this exact app (from this vendor)”—it minimizes the blast radius if the vendor ships additional tools you didn’t intend to trust. [4][3]

Sources: Apple Entitlements Troubleshooting (TN2415) [1], Apple Platform Security (team identifier for code validation) [2], Apple Code Signing Requirement Language (the identifier constraint) [3], example designated requirement including certificate leaf[subject.OU] = TEAMID [4], Microsoft Intune macOS extension allowlisting (Team ID vs specific extensions) [5].


Use certificate-based requirement (Team ID + code signing) instead of signing-identifier alone.

signing-identifier can be spoofed through ad-hoc code signing, which lacks certificate chain binding. Any sandboxed process could re-sign itself with the same identifier and gain these mach-lookup rights. Replace with certificate-based requirements (e.g., anchor apple generic and identifier "terminal-notifier" and certificate leaf[subject.OU] = "<TEAM_ID>") to bind the rule to both the specific app and its legitimate Apple-issued developer certificate. Alternatively, extract and validate the team identifier via codesign -dv before applying the profile.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox/macos-sandbox-utils.ts` around lines 507 - 519, The sandbox
profile currently scopes notification access using (signing-identifier
"terminal-notifier") which is spoofable; update the rule built under
allowNotifications/profile to use a certificate-based requirement that ties
identifier + Team ID (e.g., an anchor + identifier "terminal-notifier" and
certificate leaf[subject.OU] = "<TEAM_ID>") instead of plain signing-identifier,
or alternatively run codesign -dv to extract the Team ID at runtime and
interpolate it into the requirement; modify the string(s) pushed into profile
where the lines contain '(with-filter (signing-identifier "terminal-notifier")'
so they emit the certificate-based requirement and keep the rest of the
mach-lookup entries intact.

}

profile.push(
'; File I/O on device files',
'(allow file-ioctl (literal "/dev/null"))',
Expand Down Expand Up @@ -655,6 +675,7 @@ export function wrapCommandWithSandboxMacOS(
allowGitConfig = false,
enableWeakerNetworkIsolation = false,
allowClipboard = false,
allowNotifications = false,
binShell,
} = params

Expand Down Expand Up @@ -688,6 +709,7 @@ export function wrapCommandWithSandboxMacOS(
allowGitConfig,
enableWeakerNetworkIsolation,
allowClipboard,
allowNotifications,
logTag,
})

Expand Down
6 changes: 6 additions & 0 deletions src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ export const SandboxRuntimeConfigSchema = z.object({
.describe(
'Allow clipboard (pasteboard) access (macOS only). Required for pasting images into sandboxed processes.',
),
allowNotifications: z
.boolean()
.optional()
.describe(
'Allow notification access (macOS only). Required for tools like terminal-notifier that need window server, TCC, and dock access.',
),
Comment thread
eugene-yao-zocdoc marked this conversation as resolved.
seccomp: SeccompConfigSchema.optional().describe(
'Custom seccomp binary paths (Linux only).',
),
Expand Down
5 changes: 5 additions & 0 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,10 @@ function getAllowClipboard(): boolean {
return config?.allowClipboard ?? false
}

function getAllowNotifications(): boolean {
return config?.allowNotifications ?? false
}
Comment thread
eugene-yao-zocdoc marked this conversation as resolved.

function getSeccompConfig():
| { bpfPath?: string; applyPath?: string }
| undefined {
Expand Down Expand Up @@ -591,6 +595,7 @@ async function wrapWithSandbox(
allowGitConfig: getAllowGitConfig(),
enableWeakerNetworkIsolation: getEnableWeakerNetworkIsolation(),
allowClipboard: getAllowClipboard(),
allowNotifications: getAllowNotifications(),
binShell,
})

Expand Down