---
title: "CEL Guide - Workshop Docs"
description: "CEL Guide - Enterprise control plane for Santa. Manage rules, approvals, telemetry, and policies across your macOS fleet."
doc_version: "1"
last_updated: "2026-08-18"
canonical: "https://northpole.security/docs/workshop/rules/cel-guide"
---
# 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.

For a task-oriented reference on creating CEL rules in Workshop, see [Execution Rules](https://northpole.security/docs/workshop/rules/execution-rules#cel-policy-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](https://northpole.security/docs/workshop/img/cel-guide-create-rule.png)

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.

Field

Type

Notes

`target.signing_id`

`string`

`TeamID:SigningID` form, e.g. `EQHXZ8M8AV:com.google.Chrome`. Apple binaries use `platform:` as the prefix.

`target.team_id`

`string`

10-character Team ID. Empty for platform binaries. Requires Santa 2026.3+.

`target.is_platform_binary`

`bool`

True for binaries shipped with macOS. Requires Santa 2026.3+.

`target.signing_time`

`timestamp`

Developer-provided code signing time. Mutable by the signer, so it’s useful but not trustworthy on its own.

`target.secure_signing_time`

`timestamp`

Timestamp from Apple’s timestamping authority. Use this when “how old is this binary” needs to be cryptographically grounded.

`target.entitlements`

`map<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.

Field

Type

Notes

`args`

`list<string>`

Command-line arguments. `args[0]` is the launcher’s idea of the program name; real flags start at index 1.

`envs`

`map<string, string>`

Environment variables. Use `has(envs.NAME)` to check presence; direct access on a missing key errors.

`euid`

`int`

Effective UID. 0 is root. Requires Santa 2025.12+.

`cwd`

`string`

Current working directory of the process being executed. Requires Santa 2025.12+.

`path`

`string`

Fully resolved path of the executable. Requires Santa 2026.3+.

`ancestors`

`list<Ancestor>`

Parent process chain, immediate parent first, up to `launchd`. Workshop + Santa 2026.2+.

`fds`

`list<FileDescriptor>`

File descriptors inherited by the new process. Workshop + Santa 2026.3+.

#### `Ancestor` shape

Each entry in `ancestors` is:

Field

Type

`path`

`string` (full path of the binary)

`signing_id`

`string` (`TeamID:SigningID`, or `platform:SigningID` for Apple binaries)

`team_id`

`string` (10-character alphanumeric; empty for platform binaries)

`cdhash`

`string` (hex-encoded)

`args`

`list<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

Field

Type

`fd`

`uint` (descriptor number; 0/1/2 for stdin/out/err)

`type`

`FDType` 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.

Value

Effect

Version

`ALLOWLIST`

Allow the execution.

2025.6+

`ALLOWLIST_COMPILER`

Allow, 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+

`BLOCKLIST`

Block, show the GUI dialog.

2025.6+

`SILENT_BLOCKLIST`

Block, with no GUI or TTY notifications. Use sparingly: silent blocks are hours of confused-user-debugging waiting to happen.

2025.6+

`SILENT_GUI_BLOCKLIST`

Block, suppress the GUI dialog but still show TTY notifications.

Workshop + Santa 2026.6+

`SILENT_TTY_BLOCKLIST`

Block, suppress TTY notifications but still show the GUI dialog.

Workshop + Santa 2026.6+

`REQUIRE_TOUCHID`

Show the Santa dialog with an “Approve” button that triggers Touch ID. The execution is held, not re-launched.

Workshop + Santa 2026.1+

`REQUIRE_TOUCHID_ONLY`

Skip the Santa dialog; go straight to a Touch ID prompt.

Workshop + Santa 2026.1+

`SEATBELT`

Require the binary be run under `santactl sandbox` to enforce the process is sandboxed using a seatbelt policy attached to the rule (see [Sandbox Rules](https://northpole.security/docs/workshop/rules/sandbox-rules)). Currently in beta.

Workshop + Santa 2026.6+

`AUDIT`

Allow 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+

`UNSPECIFIED`

Reserved for fallback rules (see §5). Returning this from a normal rule is an evaluation error.

Workshop + Santa 2026.3+ (only for CEL fallback rules)

### TouchID with cooldown

The bare `REQUIRE_TOUCHID` and `REQUIRE_TOUCHID_ONLY` 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
```

Both take 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. Like the bare TouchID return values, the cooldown functions require Workshop + Santa 2026.1+.

## 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](https://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 current date at UTC midnight
days(N)                              // a duration of N days
target.signing_time > today() - days(90)   // "signed in the last ~90 days"
```

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](https://northpole.security/docs/workshop/settings#cel-fallback-rules) for configuration.

## 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 TouchID returns (bare or built by the cooldown functions) and `SEATBELT` are never cached, because they must re-run on every execution. The `today()` function 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](https://northpole.dev/cookbook/cel/) 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`; 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`](https://northpole.dev/configuration/keys#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.

## 8\. Tooling

**CEL Playground**: [northpole.dev/cookbook/cel-playground](https://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.

**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.** TouchID-with-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                                  SEATBELT
cwd                                   AUDIT
path                                  UNSPECIFIED    // fallback only
ancestors
fds                                   // Functions
                                      require_touchid_with_cooldown_minutes(N)
// FDType                             require_touchid_only_with_cooldown_minutes(N)
FD_TYPE_VNODE  FD_TYPE_SOCKET         timestamp("...")   today()   days(N)
FD_TYPE_PIPE   FD_TYPE_PSHM           has(...)
FD_TYPE_KQUEUE FD_TYPE_FSEVENTS
...                                   // String/list ops
                                      .contains  .startsWith  .endsWith
                                      .matches   .lowerAscii  .split
                                      .join      .replace     .size
                                      list.exists / .all / .filter / .map
                                      x in list  /  k in map
```

## Sitemap

- [Home](https://northpole.security/index.md)
- [Workshop](https://northpole.security/workshop.md)
- [Santa](https://northpole.security/santa.md)
- [Features](https://northpole.security/features.md)
- [Cookbook](https://northpole.security/cookbook.md)
- [Docs](https://northpole.security/docs.md)
- [Blog](https://northpole.security/blog.md)
- [Glossary](https://northpole.security/glossary.md)
- [About](https://northpole.security/about.md)
- [Contact](https://northpole.security/contact.md)
