Skip to main content

Time Based Rules

A time based rule is an execution rule whose policy is CEL and whose expression calls policy_for_range(). The expression returns one policy while a time window is open and a different one while it is closed, so a single rule can allow an application launched during working hours and block the application launches for the rest of the week. Optionally wrapping the in-range policy in kill_on_expiry() also quits the processes the rule allowed, once the window closes.

Requirements

policy_for_range(), kill_on_expiry(), now(), weekdays() and today(tz) require Workshop and Santa 2026.8. Workshop sets every rule that uses them with a minimum Santa version of 2026.8, so hosts on anything older will not receive the rule.

Overview

policy_for_range(weekdays(), "09:00", "17:00", ALLOWLIST, BLOCKLIST)

In the above example, the CEL expression allows the binary to be launched from 09:00 to 17:00, Monday through Friday, each host's own clock. At any other moment the rule blocks the execution of that binary.

Three properties are worth knowing before you write one:

  • The call is the whole expression. policy_for_range() returns a decision, so a bare call is a valid rule. There is no separate schedule object to manage: the window lives in the rule, next to the identifier it applies to.
  • The host decides. The window is evaluated on the Mac at the moment of the execution, so a host that is offline or asleep still opens and closes its windows on time.
  • The result is never cached. Any expression that calls policy_for_range() is re-evaluated on every execution. See Cacheability.

Window Forms

policy_for_range() has four forms, one per window shape. The policy arguments are always last.

FormWindowTypical use
policy_for_range(list<days>, start, end, policy, out_of_range_policy)Weekly HH:MM window on each host's own clockWorking hours, on-call hours, hours a lab machine may be used
policy_for_range(list<days>, start, end, tz, policy, out_of_range_policy)The same window read in the time zone you nameOne window fleet-wide, such as a maintenance hour in America/New_York
policy_for_range(timestamp_start, timestamp_end, policy, out_of_range_policy)One fixed span between two timestampsA dated exception: a migration week, an audit, a vendor's support window
policy_for_range(duration, kill_on_expiry(policy))A duration starting at the moment of the launchTimed access, where each launch is quit some time later

Weekly Window

policy_for_range([1, 2, 3, 4, 5], "09:00", "17:00", ALLOWLIST, BLOCKLIST)

In the above example, without a time zone argument, every host reads the window on its own clock. The list of days [1, 2, 3, 4, 5] means Monday through Friday

Weekly Window in a Named Time Zone

policy_for_range([0, 1, 2, 3, 4, 5, 6], "01:00", "05:00", "UTC", ALLOWLIST, BLOCKLIST)

With a time zone argument, every host reads the same calendar, so the window is the same four hours everywhere. The timezone argument can take "UTC", offsets like "+05:30", or IANA form like "America/New_York". Use this form for anything that has to line up with a change window, a market close, or a batch job.

Fixed Span

policy_for_range(timestamp("2026-09-14T00:00:00Z"), timestamp("2026-09-21T00:00:00Z"), ALLOWLIST, BLOCKLIST)

In the above example, the start and end timestamps are two absolute instants, so this form takes no day list and no time zone: a timestamp literal already carries its offset.

Duration

policy_for_range(duration("30m"), kill_on_expiry(ALLOWLIST))

The window is [now, now + d), so it is always open at the moment the expression runs. That is why this form takes no out of range policy, and why kill_on_expiry() is required: the form exists to set an expiry rather than to gate a decision.

Window Arguments

Days

ValueMeaning
0 to 6Sunday through Saturday, matching CEL's own getDayOfWeek()
weekdays()Shorthand for [1, 2, 3, 4, 5], Monday through Friday
[]A window that never opens. The out of range policy applies at every moment

A day outside 0 to 6 is an error.

Times of Day

start and end are 24-hour "HH:MM" strings, exactly five characters. "9:00" is rejected: write "09:00".

  • An end at or before the start crosses midnight. The day list applies to the day the window starts, so policy_for_range([5], "22:00", "06:00", ...) opens Friday at 22:00 and closes Saturday at 06:00.
  • Equal start and end covers the whole day. "00:00", "00:00" on all seven days is a window that is always open.
  • The window is half open. It includes the start minute and excludes the end minute, so back to back occurrences never overlap.

Time Zones

The tz argument in policy_for_range(...) and today(tz) accepts these three values:

ValueResolves to
"local"The host's own time zone, which is also the default when the form takes no tz
"America/New_York"Any IANA name the host's time zone database accepts, including "UTC"
"+05:30"A fixed [+-]HH:MM offset from UTC

Anything else is refused in the rule editor. Daylight saving: A window follows the local clock, so a 09:00 to 17:00 window is still 09:00 to 17:00 after the clocks change. You never edit the rule for it.

Policies in Each Slot

Both policy slots accept any CEL return value, including require_touchid_with_cooldown_minutes(N) and require_touchid_only_with_cooldown_minutes(N). The out of range slot does not have to block. For example, these three combinations cover most policies:

In rangeOut of rangeEffect
ALLOWLISTBLOCKLISTAvailable during the window, blocked outside it
ALLOWLISTrequire_touchid_with_cooldown_minutes(60)Available during the window, needs a fingerprint outside it
ALLOWLISTAUDITAlways available, and out of hours executions are flagged as audit matches

kill_on_expiry() is narrower. It accepts only policies that let a process start, because a blocked execution leaves nothing to quit:

ALLOWLIST, AUDIT, SEATBELT, REQUIRE_TOUCHID, REQUIRE_TOUCHID_ONLY, require_touchid_with_cooldown_minutes(N), require_touchid_only_with_cooldown_minutes(N).

The policy must be written out in the call. A computed policy, such as a ternary inside kill_on_expiry(), is refused.

note

A rule that can return SEATBELT must carry a seatbelt policy, window or no window. See Sandbox Rules.

Quitting Processes When the Window Closes

Without kill_on_expiry(), a window governs new executions only. A process that started inside the window keeps running after the window closes, until the user quits it. Wrapping the in range policy closes that gap:

policy_for_range(weekdays(), "09:00", "17:00", kill_on_expiry(ALLOWLIST), BLOCKLIST)

What Santa Records

Every execution the rule allows while the window is open is recorded against that rule, along with the deadline the window ends at. Nothing else is recorded: a process that started before the rule arrived, or that was allowed by a different rule, is never on the list. This is why windowed rules are non-cacheable, since a cached decision would let a process start unrecorded and so unquittable.

All the executions recorded under one rule share the earliest deadline recorded for it. A rule has one deadline, not one per launch, and a later launch never pushes it out. With a weekly or fixed window every execution ends at the same instant anyway. A countdown is where you notice it: launch the app at 10:00 under a 30 minute duration, launch it again at 10:20, and both processes are quit at 10:30.

The Warning Notification

Santa warns the user before the deadline. The lead time is 10% of the window's length, at least 5 minutes and at most an hour:

WindowWarning
8 hours48 minutes ahead
1 hour6 minutes ahead
30 minutes5 minutes ahead
Under 5 minutesAt launch

The notification reads "<App>" will quit at 5:00 PM. and lists the application, its publisher, the user, and the window it came from, rendered as 9:00 AM to 5:00 PM, Mon through Fri with the time zone appended when the rule named one. More Details adds the path, Signing ID, CDHash and parent process, and Copy Details puts all of it on the clipboard for a support ticket.

The banner appears once per deadline, and only when a recorded process is still running.

At the Deadline

Santa sends SIGTERM to every recorded process, waits 5 seconds, then sends SIGKILL to whatever is still there. Each request names the recorded execution alone: its process group is deliberately not signaled, so a child it spawned survives unless that child was recorded under the rule in its own right.

What Can Change A Pending Quit

  • A window that is open again defers. If the rule's window is standing open at the deadline, which happens with a 24-hour window or two back to back occurrences, the deadline moves to the end of the occurrence standing there and nothing is quit. A Mac that slept through a deadline wakes into the same behavior.
  • Pending quits survive a restart. They are persisted, so a daemon restart or a reboot keeps the appointment. Santa runs anything that came due while it was down, and re-arms the rest.
  • Editing or deleting the rule cancels its pending quit. The rule is re-checked at the warning and again at the deadline. The next execution under the edited rule records a fresh deadline.
  • Moving the clock backwards does not help. Santa judges every window against a time that only ever moves forward, so a rolled back system clock cannot reopen a closed window or push out a pending quit.

Examples

Working Hours, Blocked Outside Them

policy_for_range(weekdays(), "09:00", "17:00", ALLOWLIST, BLOCKLIST)

Working Hours, Touch ID Outside Them

Out of hours use stays possible with a person at the keyboard. The cooldown means one approval covers the next hour.

policy_for_range(weekdays(), "08:00", "18:00", ALLOWLIST, require_touchid_with_cooldown_minutes(60))

Measure a Window Before Enforcing It

Both policy slots allow a process. Out of hours executions arrive as audit matches, which is the list of users a blocking version of this rule would have stopped. Swap AUDIT for BLOCKLIST when that list looks right.

policy_for_range(weekdays(), "09:00", "17:00", ALLOWLIST, AUDIT)

One Maintenance Window for the Whole Fleet

policy_for_range([0, 1, 2, 3, 4, 5, 6], "01:00", "05:00", "UTC", ALLOWLIST, BLOCKLIST)

Weekends Off

Equal start and end covers the whole day, so this blocks Saturday and Sunday and allows the rest of the week.

policy_for_range([0, 6], "00:00", "00:00", BLOCKLIST, ALLOWLIST)

A Dated Exception

policy_for_range(timestamp("2026-09-14T00:00:00Z"), timestamp("2026-09-21T00:00:00Z"), ALLOWLIST, BLOCKLIST)

A Shift That Ends with the Shift

Allowed through the working day, and anything still running is quit at 17:00, with a warning 48 minutes earlier.

policy_for_range(weekdays(), "09:00", "17:00", kill_on_expiry(ALLOWLIST), BLOCKLIST)

Timed Access, Counted from the Launch

The countdown starts at the execution and the process is quit when it runs out. Ask for a fingerprint first by wrapping a Touch ID policy instead:

policy_for_range(duration("30m"), kill_on_expiry(require_touchid_only_with_cooldown_minutes(30)))

A Night Shift in a Fixed Offset

Opens at 22:00 Monday through Friday and closes at 06:00 the next morning, read at UTC+05:30 on every host.

policy_for_range([1, 2, 3, 4, 5], "22:00", "06:00", "+05:30", kill_on_expiry(require_touchid_with_cooldown_minutes(30)), BLOCKLIST)

A Window That Applies to Some Executions Only

A ternary puts the window behind another test, so ordinary use is allowed at any hour and only the conditional execution (--beta in args in the example) is timed.

"--beta" in args ? policy_for_range(duration("30m"), kill_on_expiry(ALLOWLIST)) : ALLOWLIST

Similarly ternary conditionals work with any condition a CEL rule can test, such as the effective user:

euid == 0 ? policy_for_range(weekdays(), "09:00", "17:00", kill_on_expiry(ALLOWLIST), BLOCKLIST) : ALLOWLIST

Validation

The rule editor checks the expression while you type and will not let you save one it refuses.

ExpressionWhy it is refused
policy_for_range(duration("30m"), ALLOWLIST)The duration form exists to expire access, so it requires kill_on_expiry()
kill_on_expiry(ALLOWLIST) on its ownThe wrapper is valid only as the in range policy of policy_for_range()
kill_on_expiry(BLOCKLIST)A blocked execution leaves nothing to quit
kill_on_expiry() in the out of range slotOnly the in range policy can expire
kill_on_expiry("-x" in args ? AUDIT : ALLOWLIST)The wrapped policy must be written out, not computed
policy_for_range(...) && euid == 0The call returns a decision, not a boolean. Use a ternary
One policy_for_range() inside another's argumentsCEL evaluates every argument, so the inner window would record a quit for executions it never decided. Use a ternary
"9:00", "24:00", "09:60", [7], "Mars/Olympus"Malformed time, day or time zone

One rule holds one window. To combine a window with anything else, put the call in a branch of a ternary, as in the last two examples above.

Cacheability

Santa normally caches a CEL decision per binary. Any expression that calls policy_for_range() is marked non-cacheable and is re-evaluated on every execution, which is what lets a window turn over and what makes the recording behind kill_on_expiry() complete. now() and today() have the same effect for the same reason.

The cost is one CEL evaluation per execution of the binaries the rule covers, so prefer a narrow identifier over a broad one on hot paths. See Cacheability in the CEL Guide.

Troubleshooting

The daemon logs every step of a pending quit:

/usr/bin/log stream --level debug --predicate 'sender == "com.northpolesec.santa.daemon"'
Log lineMeaning
Recorded timed rule kill for <id>: quitting at <t>, warning at <t>The first execution under the rule was recorded
Recorded execution under timed rule kill for <id> (pid …)A later execution joined the same deadline
Sending timed rule kill banner for <app> (<id>), quitting at <t>The warning notification went to the GUI
Timed rule kill firing for <id>: N recorded process(es)The deadline arrived and N processes are being quit
Timed rule kill for <id> deferred: its window is open again until <t>, nothing quitThe window was standing open at the deadline
Timed rule kill for <id> cancelled: the rule is goneThe rule was deleted before the deadline
Timed rule kill for <id> cancelled: the rule changed (rule id X -> Y)The rule was edited before the deadline
Ignoring timed rule kill for <id>: no server-assigned rule idThe rule was added locally, so no quit can be recorded
Restored N pending timed rule kill(s)Pending quits were reloaded at daemon start

Best Practices

  • Audit before you enforce. Ship the rule with AUDIT in the out of range slot, read the events for a week, then change it to BLOCKLIST.
  • Pick the time zone deliberately. Leave tz off for anything that means "the working day", and name a zone for anything that has to be the same instant everywhere.
  • Reach for Touch ID before a hard block. An out of range require_touchid_with_cooldown_minutes(N) keeps the exception path open, and every use is still recorded.
  • Warn people before you quit their work. kill_on_expiry() on a short window gives a short warning. A window of an hour or more gives users real notice.
  • Scope by code signing identity. As with any execution rule, a CDHash, Signing ID or Team ID identifier is much harder to sidestep than a binary path.
  • Roll out by tag. Scope the rule to one tag first. Hosts on Santa older than 2026.8 will silently not receive it, so confirm your fleet's versions before you rely on a window for coverage.