Skip to main content

The Complete Guide to CEL in Santa Rules

CEL (Common Expression Language) lets you attach a small program to a binary authorization rule. Instead of a static allow/block, the rule's decision is the program's return value, evaluated when a matching binary is about to execute. This guide covers every variable, every return value, every helper function, and the patterns that make CEL rules work without wrecking performance.

info

CEL rules require Santa 2025.6+. Some features in this guide require Workshop and/or newer Santa releases; version requirements are called out where they apply.

For a task-oriented reference on creating CEL rules in Workshop, see Execution Rules.

1. How a CEL rule is shaped​

A CEL rule is a normal binary authorization rule (BINARY, CDHASH, SIGNINGID, TEAMID, or CERTIFICATE) with two changes:

  • Policy is set to CEL
  • CEL Expression (cel_expr) contains the CEL program
The Workshop Create Rule dialog with the CEL policy selected

When the binary identified by the rule is about to execute, Santa evaluates the program and uses its return value as the decision. The rule's matching identifier still does the up-front work of selecting which binaries the program applies to. CEL doesn't replace targeting, it refines the decision.

2. The execution context: every available variable​

CEL programs see two kinds of state: static, tied to the contents of the executable, and dynamic, tied to this particular invocation. Static fields live under target.*. Dynamic fields are top-level.

The static/dynamic split isn't cosmetic: it controls cacheability (see §6).

2.1 target.*: the executable file (cacheable)​

These come from the Mach-O and its code signature. They don't change between invocations of the same file, so Santa can cache the program's result.

FieldTypeNotes
target.signing_idstringTeamID:SigningID form, e.g. EQHXZ8M8AV:com.google.Chrome. Apple binaries use platform: as the prefix.
target.team_idstring10-character Team ID. Empty for platform binaries. Requires Santa 2026.3+.
target.is_platform_binaryboolTrue for binaries shipped with macOS. Requires Santa 2026.3+.
target.signing_timetimestampDeveloper-provided code signing time. Mutable by the signer, so it's useful but not trustworthy on its own.
target.secure_signing_timetimestampTimestamp from Apple's timestamping authority. Use this when "how old is this binary" needs to be cryptographically grounded.
target.entitlementsmap<string, string>Entitlements from the code signature as a map of key → JSON string. Booleans are the JSON strings "true" / "false", not CEL booleans. Requires Workshop + Santa 2026.3+.

2.2 Top-level execution fields (not cacheable)​

Touching any of these flips the program to non-cacheable for this invocation. Used judiciously this is fine; used on a binary that runs thousands of times a day, it hurts.

FieldTypeNotes
argslist<string>Command-line arguments. args[0] is the launcher's idea of the program name; real flags start at index 1.
envsmap<string, string>Environment variables. Use has(envs.NAME) to check presence; direct access on a missing key errors.
euidintEffective UID. 0 is root. Requires Santa 2025.12+.
cwdstringCurrent working directory of the process being executed. Requires Santa 2025.12+.
pathstringFully resolved path of the executable. Requires Santa 2026.3+.
ancestorslist<Ancestor>Parent process chain, immediate parent first, up to launchd. Workshop + Santa 2026.2+.
fdslist<FileDescriptor>File descriptors inherited by the new process. Workshop + Santa 2026.3+.

Ancestor shape​

Each entry in ancestors is:

FieldType
pathstring (full path of the binary)
signing_idstring (TeamID:SigningID, or platform:SigningID for Apple binaries)
team_idstring (10-character alphanumeric; empty for platform binaries)
cdhashstring (hex-encoded)
argslist<string> (command line arguments). Needs Workshop + Santa 2026.3+

ancestors[0] is the immediate parent, ancestors[1] its parent, and so on. Walk the chain with exists() or index directly; examples are in §7.

FileDescriptor shape​

FieldType
fduint (descriptor number; 0/1/2 for stdin/out/err)
typeFDType enum (see below)

FDType is an enum, and its values are exposed as bare identifiers in CEL, so you write FD_TYPE_PIPE, not a quoted string:

FD_TYPE_UNKNOWN FD_TYPE_PSEM FD_TYPE_NETPOLICY
FD_TYPE_ATALK FD_TYPE_KQUEUE FD_TYPE_CHANNEL
FD_TYPE_VNODE FD_TYPE_PIPE FD_TYPE_NEXUS
FD_TYPE_SOCKET FD_TYPE_FSEVENTS
FD_TYPE_PSHM

The two you'll actually use most: FD_TYPE_PIPE and FD_TYPE_SOCKET. They're the foundation for detecting curl | bash-style pipe chains.

3. Return values​

A CEL program ends with either a bool (where true → ALLOWLIST, false → BLOCKLIST) or one of the named return values below. Like FDType, these are bare identifiers, not strings.

ValueEffectVersion
ALLOWLISTAllow the execution.2025.6+
ALLOWLIST_COMPILERAllow, and if transitive allowlisting is enabled, record any Mach-O outputs as locally allowed for 6 months. Only meaningful for actual compilers/linkers.2025.6+
BLOCKLISTBlock, show the GUI dialog.2025.6+
SILENT_BLOCKLISTBlock, with no GUI or TTY notifications. Use sparingly: silent blocks are hours of confused-user-debugging waiting to happen.2025.6+
SILENT_GUI_BLOCKLISTBlock, suppress the GUI dialog but still show TTY notifications.Workshop + Santa 2026.6+
SILENT_TTY_BLOCKLISTBlock, suppress TTY notifications but still show the GUI dialog.Workshop + Santa 2026.6+
REQUIRE_TOUCHIDShow the Santa dialog with an "Approve" button that triggers Touch ID. The execution is held, not re-launched.Workshop + Santa 2026.1+
REQUIRE_TOUCHID_ONLYSkip the Santa dialog; go straight to a Touch ID prompt.Workshop + Santa 2026.1+
REQUIRE_SECURITY_KEYShow the Santa dialog, then hold the execution until the user touches a FIDO2 hardware security key attached to the Mac. Touch ID does not satisfy it. See Security keys.Workshop + Santa 2026.9+
REQUIRE_SECURITY_KEY_ONLYSkip the Santa dialog; go straight to a security key prompt.Workshop + Santa 2026.9+
REQUIRE_PRESENCEShow the Santa dialog, then hold the execution until the user authorizes with either Touch ID or a security key.Workshop + Santa 2026.9+
REQUIRE_PRESENCE_ONLYSkip the Santa dialog; go straight to a Touch ID or security key prompt.Workshop + Santa 2026.9+
SEATBELTRequire the binary be run under santactl sandbox to enforce the process is sandboxed using a seatbelt policy attached to the rule (see Sandbox Rules). Currently in beta.Workshop + Santa 2026.6+
AUDITAllow the execution but flag the resulting sync event as an audit-rule match (audit_return) so it's distinguishable from a normal allowlist hit. Useful for deploying/debugging complex CEL rules.Workshop + Santa 2026.5+
UNSPECIFIEDReserved for fallback rules (see §5). Returning this from a normal rule is an evaluation error.Workshop + Santa 2026.3+ (only for CEL fallback rules)

Authorization with cooldown​

The bare authorization return values prompt every execution. To behave like sudo (verify once, then allow follow-ups for N minutes), return a value built by one of these functions:

require_touchid_with_cooldown_minutes(N) // returns REQUIRE_TOUCHID + cooldown
require_touchid_only_with_cooldown_minutes(N) // returns REQUIRE_TOUCHID_ONLY + cooldown
require_security_key_with_cooldown_minutes(N) // returns REQUIRE_SECURITY_KEY + cooldown
require_security_key_only_with_cooldown_minutes(N) // returns REQUIRE_SECURITY_KEY_ONLY + cooldown
require_presence_with_cooldown_minutes(N) // returns REQUIRE_PRESENCE + cooldown
require_presence_only_with_cooldown_minutes(N) // returns REQUIRE_PRESENCE_ONLY + cooldown

Each takes an integer number of minutes. 0 (and any negative value, which is normalized to 0) means "prompt every time", the same as the bare constant, but explicit. Each function requires the same Workshop and Santa version as the return value it builds. Santa caches an approval against the method the user authorized with, so a Touch ID approval never satisfies a later REQUIRE_SECURITY_KEY hold.

Security keys​

REQUIRE_SECURITY_KEY covers the Macs where Touch ID is unavailable, such as a desktop Mac or a laptop in clamshell mode, with something that has to be physically present and cannot be typed. Use REQUIRE_PRESENCE where either Touch ID or a key will do. Nothing needs enabling on the host: the rule is the opt-in.

A REQUIRE_SECURITY_KEY hold with no key attached is denied. A REQUIRE_PRESENCE hold with no key attached falls back to Touch ID.

euid == 0 ? REQUIRE_SECURITY_KEY : ALLOWLIST
warning

Santa checks user presence only: that someone touched a FIDO2 key attached to the Mac. It does not check which key, or whose, so treat it as proof that a person was at the machine, not proof of identity. See User Authorization in the Santa docs.

4. The Common Expression Language (CEL) at a glance​

CEL is a Google-developed expression language. Santa registers the CEL standard library plus CEL's string extensions, which adds the methods you actually want for argv-mashing. A good place to start with generic CEL is celbyexample.com.

4.1 Operators​

== != < <= > >=
&& || !
+ - * / %
?: // ternary
in // membership: x in list, x in map
has(msg.field) // presence check on proto / map keys

has() is the only safe way to test for an environment variable. envs.FOO == "bar" will error if FOO isn't set.

4.2 String methods (standard + extensions)​

s.size()
s.contains("x") s.startsWith("x") s.endsWith("x")
s.matches("regex") // RE2 syntax
s.lowerAscii() s.upperAscii()
s.replace(old, new) s.split(sep)
s.indexOf(sub) s.substring(start, end)
s.trim() s.charAt(i)

Two notes that bite people:

  • matches() uses RE2: no lookarounds, no backreferences, anchors with ^/$ on the whole string. Backslashes double once inside the CEL string literal (the regex \W is written "\\W"), and a JSON sync payload doubles them again (see §9).
  • lowerAscii() is what you want over an uppercase comparison for any user-supplied string. display dialog vs Display Dialog is the difference between a working rule and a working bypass.

4.3 List and map methods​

list.size() // also map.size()
elem in list // also key in map
list[i] // indexing; out-of-range is an error
list + list // concatenation
list.join(sep) // strings extension, e.g. args.join(" ")

4.4 Comprehensions​

These are the real workhorses. The variable name (x, arg, f) is yours to pick.

list.exists(x, predicate(x)) // any
list.all(x, predicate(x)) // every
list.exists_one(x, predicate(x)) // exactly one
list.filter(x, predicate(x)) // subset
list.map(x, transform(x)) // new list

Two patterns you'll write often:

// Did any arg match this flag set?
args.exists(a, a in ['--inspect', '--inspect-brk', '--remote-debugging-port'])
// Is there a parent that's Slack?
ancestors.exists(a, a.signing_id == "BQR82RBBHL:com.tinyspeck.slackmacgap")

4.5 Timestamps​

timestamp('2025-05-31T00:00:00Z') // RFC 3339 string
timestamp(1748736000) // unix seconds
ts1 - ts2 // duration
ts1 < ts2 // comparison

target.signing_time and target.secure_signing_time are optional; if the binary has no signing time, the field reads as the zero value (the Unix epoch), so comparisons are well-defined. If absence matters, guard the relevant field with field != timestamp(0). (Don't use target.team_id != "" as a signedness guard: signed platform binaries have an empty Team ID.)

Workshop + Santa 2026.6+ also add two helper functions for relative-timestamp rules:

today() // the start of the current day
days(N) // a duration of N days
target.signing_time > today() - days(90) // "signed in the last ~90 days"

today() reads the day boundary on the host: on Santa 2026.6 and 2026.7 it is the start of the current UTC day, and from Santa 2026.8 it is the start of the current day in the host's own time zone. Rules that only compare dates weeks or months apart are unaffected by the change; a rule whose answer turns over at the boundary now turns over at local midnight. From 2026.8 you can also pin a zone explicitly with today(tz), where tz is "local", an IANA name like America/New_York, or a +05:30 style UTC offset.

Because today() changes value daily, any rule that references it is non-cacheable (see §6).

5. Fallback rules (Workshop + Santa 2026.3+)​

A fallback rule is a CEL expression that runs only when no specific rule matches a binary. It's the policy of last resort, evaluated after the normal rule lookup comes up empty. This closes the gap that Monitor mode leaves by default: instead of "unknown means allow," fallback gives you "unknown means run this program."

A fallback rule returning UNSPECIFIED means "I have no opinion" and passes the decision to the next fallback rule in the chain. When every configured fallback rule returns UNSPECIFIED (or none is configured), Santa falls through to its default client-mode behavior. Anything else takes effect.

Two patterns this enables that previously needed a forest of rules:

Block by entitlement. Apple gates certain capabilities behind entitlements that only ever appear in code signatures, never in argv. A fallback rule sees them directly:

// Block any binary with the hypervisor/virtualization entitlements
'com.apple.security.hypervisor' in target.entitlements ||
'com.apple.security.virtualization' in target.entitlements
? BLOCKLIST : UNSPECIFIED
// Block unapproved network extensions (VPNs, content filters, DNS proxies)
'com.apple.developer.networking.networkextension' in target.entitlements
? BLOCKLIST : UNSPECIFIED

Block by execution path. Stops staging-directory execution for anything without a specific allow rule:

path.startsWith('/tmp/') ||
path.startsWith('/private/var/tmp/') ||
path.matches('^/Users/[^/]+/Downloads/')
? BLOCKLIST : UNSPECIFIED

Fallback rules are configured in Workshop. They sit outside the normal rule set and can be authored per-tag. See Settings for configuration.

Workshop records the oldest Santa version that can run each fallback rule. A host running an older version, or one whose version Workshop can't read, doesn't receive that rule, but still receives the others.

6. The caching model​

The cost of a CEL rule isn't the evaluation, it's how often Santa has to evaluate it. By default Santa caches the result of each authorization decision keyed by the binary, so a CEL program runs once and the answer sticks. Touching any non-target field disables that cache for that execution, and the program runs every time the binary launches.

What disables caching:

  • args, envs, euid, cwd, path
  • ancestors, fds

What stays cacheable:

  • Everything under target.*, including target.entitlements
  • The plain allow/block return values: ALLOWLIST, ALLOWLIST_COMPILER, BLOCKLIST, SILENT_BLOCKLIST (and the GUI/TTY variants)

Independent of which fields the program reads, some return values force the result non-cacheable: the Touch ID, security key and presence returns (bare or built by the cooldown functions) and SEATBELT are never cached, because they must re-run on every execution. The today() function (with or without a zone) also disables caching, since its value changes daily.

This is determined at runtime by which activation fields the program actually reads, not by static analysis of the expression text. Memoization inside the activation means each field is only fetched from the kernel once per evaluation, but cacheability is a single bit: read one non-cacheable field and the whole result is non-cacheable.

Practical rules of thumb:

  1. For frequently-executed binaries, prefer target.*. Anything under /usr/libexec/ or that fires on every login session is a hot path. A non-cacheable rule on xpcproxy is a bad day.
  2. Put the cheap, static condition first in a ternary. CEL doesn't promise short-circuit ordering across all backends, but target.is_platform_binary && args.exists(...) is at least readable as "platform check gates the dynamic check."
  3. For one-off binaries (browsers, dev tools, admin commands), non-cacheable is fine. A user launches Chrome a handful of times a day, not a thousand.
  4. If you only need dynamic state for some launches, structure the rule to bail early. A rule that checks args only when entitlements indicate it's worth checking can stay cacheable for the majority of executions.

7. Recipes​

The northpole.dev cookbook has a set of worked examples; here are categories of patterns that cover the rest of the surface area, including everything only available with Workshop.

7.1 Signing-time freshness​

Force upgrades by refusing to run binaries signed before a cutoff. Pair with a SIGNINGID rule for one app, or a TEAMID rule with a CEL prefix check (§7.2) to cover a whole vendor.

// Use secure_signing_time for the cryptographically-anchored version.
target.secure_signing_time >= timestamp('2026-01-01T00:00:00Z')
? ALLOWLIST : BLOCKLIST

For an app that fell out of the vendor's update cycle, you can also enforce "the last good version was signed before X": anything signed after the cutoff is unexpected and gets blocked:

target.secure_signing_time < timestamp('2024-09-15T00:00:00Z')
? ALLOWLIST : BLOCKLIST

7.2 Consolidating multiple SigningIDs onto a TeamID​

Attach to a TEAMID rule to cover a whole vendor with one rule plus a CEL filter. This is useful when a vendor ships a dozen helper binaries you'd otherwise need a dozen rules for:

target.signing_id.startsWith("EQHXZ8M8AV:com.google.Chrome") ||
target.signing_id in [
"EQHXZ8M8AV:com.google.GoogleUpdater",
"EQHXZ8M8AV:com.google.Keystone"
] ? ALLOWLIST : BLOCKLIST

7.3 Entitlement-based decisions​

Entitlements describe capability, not just identity. They're readable from any binary that declares them, and they're cacheable. Use them to gate sensitive privileges regardless of who shipped the binary.

// Block binaries that opt out of library validation
// (can load arbitrary unsigned dylibs)
'com.apple.security.cs.disable-library-validation' in target.entitlements
? BLOCKLIST : ALLOWLIST

Entitlement values are JSON strings, so for boolean entitlements compare to "true"/"false". Guard the index with in first: indexing a key the binary doesn't have is an evaluation error, not false.

'com.apple.security.app-sandbox' in target.entitlements &&
target.entitlements['com.apple.security.app-sandbox'] == "true"
? ALLOWLIST : BLOCKLIST

7.4 Process-tree-aware rules (ancestors)​

The most common ancestor pattern is "this binary is fine, except when launched by X." Attach to a SigningID rule for the target binary; let the CEL program decide based on parents.

Block a binary spawned from an AI coding agent:

// platform:com.apple.curl with this CEL: deny when an AI agent is in the chain
// (placeholder identifiers; substitute the vendors' actual signing IDs)
ancestors.exists(a,
a.signing_id in [
"AAAAAAAAAA:com.example.ai-agent",
"BBBBBBBBBB:com.example.coding-cli"
]) ? BLOCKLIST : ALLOWLIST

Block shells spawned from productivity apps (the classic Excel macro shape):

// Attach to platform:com.apple.bash (or sh, zsh, etc.)
ancestors.exists(a,
a.signing_id in [
"UBF8T346G9:com.microsoft.Excel",
"UBF8T346G9:com.microsoft.Word",
"UBF8T346G9:com.microsoft.Powerpoint"
]) ? BLOCKLIST : ALLOWLIST

Restrict developer tools to IDEs:

// Allow a compiler or build tool only when an IDE is somewhere upstream
ancestors.exists(a,
a.team_id in ["UBF8T346G9", "2ZEFAR8TH3"] || // Microsoft (VS Code), JetBrains
a.signing_id.contains(".Xcode"))
? ALLOWLIST : BLOCKLIST

The ancestors[].args field (Santa 2026.3 + Workshop) lets you make ancestor checks even more specific: for example, only allowing git to run when a parent shell was invoked from a known development directory or with certain arguments.

7.5 Pipe-chain detection (curl | bash)​

The fds field exposes the descriptor table at exec time. When you write curl … | bash, the shell's stdin is a pipe. That's a signal a normal interactive shell session doesn't carry.

Attach to a SigningID rule for platform:com.apple.bash / zsh / sh:

// Block when stdin is a pipe (curl | bash, wget | sh, etc.)
fds.exists(f, f.fd == 0u && f.type == FD_TYPE_PIPE)
? BLOCKLIST : ALLOWLIST

Note 0u: fd is uint, so the literal needs the u suffix.

For a richer signal, also flag stdin attached to a socket (some droppers use that instead of a pipe):

fds.exists(f,
f.fd == 0u && (f.type == FD_TYPE_PIPE || f.type == FD_TYPE_SOCKET))
? BLOCKLIST : ALLOWLIST

If you want this as a Touch ID gate rather than a hard block, swap BLOCKLIST for REQUIRE_TOUCHID_ONLY (or REQUIRE_PRESENCE_ONLY to accept a security key too); physical presence is exactly the property a script-piped-into-a-shell can't satisfy.

7.6 Argument inspection patterns​

The most common shape: block specific flags on a platform binary. The pattern is args.exists(a, a in [...]) because it's quicker to read than chained ||s.

spctl (Gatekeeper disable, covered in the cookbook):

args.exists(a, a in
['--global-disable', '--master-disable', '--disable', '--add', '--remove'])
? BLOCKLIST : ALLOWLIST

security (keychain dumping and trust anchor manipulation):

args.exists(a, a in [
'dump-keychain', 'find-generic-password', 'find-internet-password',
'find-identity', 'add-trusted-cert', 'add-certificates',
'unlock-keychain', 'set-key-partition-list'
]) ? BLOCKLIST : ALLOWLIST

dscl (local auth probing and shadow hash extraction):

// Block password validation and shadow hash reads
'-authonly' in args ||
('-read' in args && 'dsAttrTypeNative:ShadowHashData' in args)
? BLOCKLIST : ALLOWLIST

xattr (quarantine attribute stripping):

args.join(" ").contains("-d com.apple.quarantine") ||
'-cr' in args
? BLOCKLIST : ALLOWLIST

Chrome with remote debugging (gate, don't block):

args.exists(a, a.contains("--remote-debugging-port="))
? REQUIRE_TOUCHID_ONLY : ALLOWLIST

Electron run-as-Node:

// Attach to the Electron app's SigningID (e.g. VS Code)
has(envs.ELECTRON_RUN_AS_NODE) || args.exists(a, a.contains("--inspect"))
? BLOCKLIST : ALLOWLIST

Cooldown variant (Touch ID once per hour for a high-impact admin command):

// Attach to whatever signing ID covers `kubectl exec`-equivalents
args.exists(a, a == "exec")
? require_touchid_only_with_cooldown_minutes(60)
: ALLOWLIST

7.7 Working-directory-aware rules​

Santa 2025.12+ exposes cwd, which makes a timestomping rule robust against cd ~/Library/LaunchAgents && touch foo.plist. The cookbook's touch timestomping rule, with the cwd extension:

args.exists(a, a in ['-a', '-m', '-r', '-A', '-t']) &&
((args.join(" ").contains("Library/Launch") || cwd.contains("Library/Launch"))
|| (cwd.endsWith("Library") &&
(args.join(" ").contains("./Launch") ||
args.join(" ").contains(" Launch"))))
? BLOCKLIST : ALLOWLIST

7.8 Root vs non-root rules​

// Allow only when not running as root
euid != 0
// Or: TouchID for root, normal for everyone else
euid == 0 ? REQUIRE_TOUCHID_ONLY : ALLOWLIST

7.9 Path-based scoping​

// Apple's curl, only when invoked from /usr/bin (not a copy somewhere weird)
target.is_platform_binary && path.startsWith('/usr/bin/')

7.10 Auditing without blocking (Workshop + Santa 2026.5+)​

AUDIT returns are great for staged rollouts. Write the rule the way you'd write it for blocking, but return AUDIT instead of BLOCKLIST. The execution proceeds, but the resulting sync event is tagged so you can find every match and review whether the rule would have caused breakage if it had been blocking.

ancestors.exists(a, a.signing_id == "UBF8T346G9:com.microsoft.Excel")
? AUDIT : ALLOWLIST

Switch the AUDIT to BLOCKLIST once you're confident the rule's hits are all actually bad.

(The AUDIT return value first appeared in Santa 2026.4, but audit events only reliably reach the sync server on every match from Santa 2026.5, so treat 2026.5 as the practical minimum.)

7.11 Combining static and dynamic conditions​

The cheapest possible non-cacheable rule does its dynamic check only after a target.* short-circuit:

// Cacheable when the binary doesn't have the entitlement; checks args otherwise.
// (Note: actual cacheability is a single bit per evaluation; if any path reads
// args, the result for this evaluation is non-cacheable. But the eval cost is
// still smaller in the common case.)
'com.apple.private.dangerous-thing' in target.entitlements
? (args.exists(a, a == '--really-do-it') ? BLOCKLIST : ALLOWLIST)
: ALLOWLIST

7.12 Conditional compiler designation​

ALLOWLIST_COMPILER is a return value like any other, so CEL can decide when a binary is a compiler. This is more useful than the static rule equivalent: a plain ALLOWLIST_COMPILER rule on clang treats clang --version and clang -E - as compilers, which means any Mach-O they happen to touch gets a 6-month local rule. Gating the decision avoids that.

Requires EnableTransitiveRules; without it, ALLOWLIST_COMPILER is silently equivalent to ALLOWLIST. Rules that read like they should be doing something will look broken if the config key isn't set.

Compiler only when actually producing output:

// Only treat clang as a compiler when it's writing something
args.exists(a, a == '-c') ||
args.exists(a, a == '-o') ||
args.exists(a, a.startsWith('--output'))
? ALLOWLIST_COMPILER : ALLOWLIST

Compiler only when driven by a real build tool: This is the pattern that makes codesign safe to designate as a compiler. Without scoping, ad-hoc codesign --force --sign - /path/to/anything becomes a transitive-rule factory.

ancestors.exists(a,
a.signing_id.startsWith("59GAB85EFG:com.apple.dt.") || // Xcode, xcodebuild
a.team_id == "2ZEFAR8TH3") // JetBrains
? ALLOWLIST_COMPILER : ALLOWLIST

Scope compiler trust to the canonical toolchain copy:

target.is_platform_binary && path.startsWith('/usr/bin/')
? ALLOWLIST_COMPILER : ALLOWLIST

(Note that on machines with Xcode installed, /usr/bin/clang is a shim that executes the toolchain copy inside Xcode.app, so pair this with the ancestor pattern above for developer fleets.)

Scope to known build roots:

cwd.startsWith('/Users/') &&
(cwd.contains('/src/') || cwd.contains('/build/') || cwd.contains('/Developer/'))
? ALLOWLIST_COMPILER : ALLOWLIST

Freshness gate on compiler trust (don't let an old toolchain mint new rules):

target.secure_signing_time >= timestamp('2025-01-01T00:00:00Z') &&
args.exists(a, a == '-c' || a == '-o')
? ALLOWLIST_COMPILER : ALLOWLIST

Notes:

  • Cacheability rules still apply. The target.*-only variants stay cacheable. The moment you touch args, cwd, or ancestors, the result is non-cacheable for that evaluation. That's usually fine on a developer's machine where clang runs hundreds of times an hour, not millions, but worth knowing.
  • The transitive rules that get created are normal local Santa rules (by hash / signing ID), not CEL rules. You're not delegating CEL logic to the children, just allowlisting their outputs for 6 months on that host.
  • Santa 2026.2 added clonefile tracking to the transitive-rule mechanism, which is what makes this work cleanly for rustc / cargo-style toolchains that produce outputs via clone rather than create-and-write.

7.13 Process annotations (Workshop + Santa 2026.9+)​

An ancestor check like the one in §7.4 runs again for every process in the tree. Annotations let one rule do the expensive match once and mark the process. Its descendants inherit the mark, and later rules or fallback rules test it with a lookup.

  • add_annotation(name, policy) marks the executing process with name and returns policy unchanged. Pass a list of names to set several at once.
  • add_annotation(name, propagation, policy) also controls how far the mark travels.
  • has_annotation(name) is true when the executing process carries name.

Mark a build tool, then trust what it runs:

// Rule on bazel: mark it, and every process it starts, as part of a build
add_annotation("BAZEL-CALL", FORK_AND_EXEC, ALLOWLIST)
// Fallback rule: anything a build runs is a compiler
has_annotation("BAZEL-CALL") ? ALLOWLIST_COMPILER : UNSPECIFIED

The propagation argument takes one of these values:

ValueThe mark follows
FORK_AND_EXECEvery descendant. This is the default when you leave it out
EXEC_ONLYThis process across its own exec, but not its children
FORK_ONLYForked children until they exec
NONEThis process only

has_annotation() runs against the process after its exec, so it never sees a NONE or FORK_ONLY mark. Those two exist for telemetry.

Notes:

  • add_annotation() must produce the rule's result. Put it on a ternary branch or around the policy the rule returns. Workshop and Santa reject it anywhere else, such as in a condition or inside another function's arguments, because CEL evaluates arguments eagerly and the mark would be set for a decision the rule never made.
  • The mark is set only when the whole expression evaluates without an error.
  • has_annotation() sees the process as it was when evaluation started. It doesn't see a mark that an add_annotation() in the same expression sets.
  • Any expression that uses either function is never cacheable. The answer depends on the process, not the binary.
  • In Workshop's rule editor, has_annotation() always evaluates to false, because only Santa has the process tree.
  • Execution events list the annotations on the process that ran. They appear on the event details page, and you can filter the events table by them.
  • Both functions work in fallback rules. Hosts running a Santa version older than 2026.9 don't receive a fallback rule that uses them.

8. Tooling​

CEL Playground: northpole.dev/cookbook/cel-playground runs an expression against a supplied YAML activation in the browser. Every cookbook entry has a "Try in Playground →" link with the example pre-loaded.

santactl fileinfo: Pulls the SHA-256, CDHash, Team ID, signing ID, signing chain, and the rule Santa would apply. The --verify flag (2026.1+) also runs code-signature verification and a Gatekeeper assessment, which is the fastest way to figure out why a rule isn't applying when you think it should.

santactl fileinfo's expected-decision field (2026.3+): Tells you what Santa expects to do with a file based on rules alone. Runtime context (ancestors, args, etc.) can still change the actual decision when the rule's CEL runs.

santactl rule --check: Verifies a specific identifier against the current rule database.

9. Pitfalls and gotchas​

envs.FOO errors on missing keys. Always gate with has(envs.FOO) before reading. The same trap applies to target.entitlements['x'], but because entitlement keys contain dots, they can't use has() (it only accepts field-selection syntax). Use 'x' in target.entitlements instead.

in and contains() are different matches. x in ['a', 'b'] is true only when x equals one of the list items. x.contains('a') is true when a is anywhere in the string x. For a list of exact values, use in. For substring matches, use contains().

Regex escaping stacks per layer. The regex \W+display is written "\\W+display" as a CEL string literal, because CEL strings escape backslashes. A plist <string> carries the CEL program verbatim from there (XML only entity-escapes characters like < and &), but a JSON sync payload escapes each backslash again: "\\\\W+display". Test in the Playground first.

SIGNINGID and TEAMID rules don't match development-signed code. A CEL program attached to a SigningID rule will never see binaries signed with a dev cert. If you need to target dev-signed binaries, use BINARY, CDHASH, or CERTIFICATE as the rule type.

SILENT_BLOCKLIST is a debugging trap. Use it for rules where the user can do nothing useful with a notification (background daemons, telemetry-only flows). Anything a human ever launches deserves a normal block.

Ancestors can be reparented. macOS detaches background services from their launching session; what launchctl runs may have launchd (pid 1) as its only ancestor by the time Santa sees it. Ancestor rules are great signal for foreground process trees; they're not a complete guard for daemonized launches.

Cooldowns and standalone mode interact. An authorization cooldown counts a single approval against future executions of the same binary. Different binaries that hit the same rule prompt independently.

ALLOWLIST_COMPILER only does something when transitive allowlisting is enabled. Without EnableTransitiveRules, it's just ALLOWLIST. Don't reach for it unless you've actually turned the feature on.

Cacheable doesn't mean free. It means "evaluated once per binary." A CEL rule with a complex regex still pays the full compile-and-match cost whenever it does run, and a non-cacheable rule runs on every launch. Keep expressions lean on hot paths.

10. Quick reference card​

// Variables // Return values
target.signing_id ALLOWLIST
target.team_id ALLOWLIST_COMPILER
target.is_platform_binary BLOCKLIST
target.signing_time SILENT_BLOCKLIST
target.secure_signing_time SILENT_GUI_BLOCKLIST
target.entitlements SILENT_TTY_BLOCKLIST
args REQUIRE_TOUCHID
envs REQUIRE_TOUCHID_ONLY
euid REQUIRE_SECURITY_KEY
cwd REQUIRE_SECURITY_KEY_ONLY
path REQUIRE_PRESENCE
ancestors REQUIRE_PRESENCE_ONLY
fds SEATBELT
AUDIT
// FDType UNSPECIFIED // fallback only
FD_TYPE_VNODE FD_TYPE_SOCKET
FD_TYPE_PIPE FD_TYPE_PSHM // Functions
FD_TYPE_KQUEUE FD_TYPE_FSEVENTS require_touchid[_only]_with_cooldown_minutes(N)
... require_security_key[_only]_with_cooldown_minutes(N)
require_presence[_only]_with_cooldown_minutes(N)
// Propagation timestamp("...") today() days(N)
FORK_AND_EXEC EXEC_ONLY has(...)
FORK_ONLY NONE add_annotation(name, [propagation,] policy)
has_annotation(name)

// String/list ops
.contains .startsWith .endsWith
.matches .lowerAscii .split
.join .replace .size
list.exists / .all / .filter / .map
x in list / k in map