# North Pole Security: full documentation > Concatenation of all Workshop documentation, Santa documentation, and blog posts on northpole.security. Follows the llmstxt.org full-text convention. # Workshop documentation ## Introduction # Introduction to Workshop Welcome to Workshop, the comprehensive administration console for managing Santa deployments across thousands of hosts. Workshop provides a centralized interface for monitoring, configuring, and maintaining your Santa endpoint security infrastructure. For more detailed information about Santa, visit the [Santa Documentation](https://northpole.dev/). --- ## AI # AI Workshop provides AI-powered features to help you manage and understand your endpoint security environment. ## AI Chat AI Chat lets you ask natural-language questions about your Workshop data directly from the dashboard. Chat sessions have the same permissions as the logged-in user — the AI assistant can only access data you're authorized to see. ### Setup 1. Go to Settings → AI → Chat 2. Toggle **Enabled** 3. Select an AI provider (Anthropic, OpenAI, or Google) 4. Enter your API key for the chosen provider 5. Optionally select a specific model (defaults are recommended) ### Privacy :::warning Information you send to AI Chat — including your questions, conversation history, and any Workshop data retrieved by the assistant — is sent to whichever third-party AI provider your organization has configured. Review your provider's data handling policies before enabling AI Chat. ::: ### Supported Providers - Anthropic - OpenAI - Google ### What You Can Do AI Chat can query your Workshop data using the same API methods available through the web interface. Example questions: > Show me a summary of all rules in Workshop. > Why is <app name> blocked on <host name>? > What are the top 10 most executed applications across my fleet? > Are any of my hosts out of date? The assistant uses tools to look up data, perform calculations, and query Workshop documentation. By default, the assistant cannot modify your configuration. To enable write access, toggle **Read-Write Mode** in AI Chat settings. --- ## MCP Server The Model Context Protocol (MCP) is an open protocol that standardizes how applications provide context to large language models (LLMs). Learn more at [modelcontextprotocol.io](https://modelcontextprotocol.io). Workshop's MCP server exposes all of the methods available in the Workshop API to MCP-compatible clients such as Claude Desktop, Claude Code, LM Studio, and Gemini CLI. ### Getting Started #### 1. Enable the MCP Server 1. Go to Settings → AI → MCP 2. Toggle the switch to enable the MCP server :::warning By default the MCP server only allows read-only access, even if you have added `write` permissions to the API key or OAuth scope. You must enable read-write mode in the MCP settings to allow MCP clients to make changes. ::: #### 2. Choose an Authentication Method {#choose-auth-method} **OAuth 2.0 (recommended):** MCP clients that support OAuth will automatically prompt you to log in — no extra setup needed. Just point the client at your Workshop MCP URL and authenticate through the browser. **API key (alternative):** If your MCP client doesn't support OAuth, or you prefer key-based auth, generate an API key: 1. Go to Settings → API Keys 2. Click "Create API Key" 3. Copy the key (it starts with `npsws_sk_`) ### Authentication #### OAuth 2.0 MCP clients that support OAuth 2.0 can authenticate using your organization's identity provider. This is the recommended approach. OAuth users receive permissions based on their Workshop role assignment. The MCP read-write toggle in settings provides an additional layer of control over write access. #### API Key Alternatively, create an API key with the desired permissions and pass it in the `Authorization` header. See [Choose an Authentication Method](#choose-auth-method) above. ### Integrating with MCP #### Claude Desktop 1. **Install Claude Desktop** from [claude.ai](https://claude.ai/download) 2. Open **Settings** → **Connectors** 3. Click **Add custom connector** 4. Enter your Workshop MCP URL: `https://example.workshop.cloud/mcp` 5. Click **Add** — Claude will open a browser window for OAuth authentication See the [Claude custom connectors documentation](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) for more details. #### Claude Code 1. **Install Claude Code** from [claude.ai](https://claude.ai/download) 2. Run the following command to add the Workshop MCP server: ``` claude mcp add --transport http workshop https://example.workshop.cloud/mcp ``` Claude Code will open a browser window for OAuth authentication when you first connect. See the [Claude Code MCP documentation](https://docs.anthropic.com/en/docs/claude-code/mcp) for more details. #### LM Studio 1. **Install LM Studio** from [lmstudio.ai](https://lmstudio.ai) 2. Open the **Program** tab in the right sidebar 3. Click **Install** → **Edit mcp.json** and add: ```json { "mcpServers": { "workshop": { "url": "https://example.workshop.cloud/mcp", "headers": { "Authorization": "npsws_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } } } ``` As of March 2026, LM Studio does not support OAuth for remote MCP servers, so an API key is required. See the [LM Studio MCP documentation](https://lmstudio.ai/docs/app/mcp) for more details. #### Gemini CLI 1. **Install Gemini CLI** from [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) 2. Run the following command to add the Workshop MCP server: ``` gemini mcp add --transport http workshop https://example.workshop.cloud/mcp ``` See the [Gemini CLI MCP documentation](https://geminicli.com/docs/tools/mcp-server/) for more details. ### Per-connection tool selection By default, every connection sees the same three tools: `list_operations`, `describe_operation`, and `call_operation`. The model calls `list_operations` to discover what it can do, `describe_operation` to learn an operation's request shape, then `call_operation` to invoke it. A connection can opt into a different surface instead: one MCP tool per Workshop API operation, each with its own name and JSON schema. This is useful for clients that approve tool calls one tool at a time — `call_operation` is too generic a target for that kind of review, since it can invoke anything the caller is permitted to. Configure the surface per connection with request headers, no server-side setting required: | Header | Effect | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `X-MCP-Toolsets` | Comma-separated toolset slugs to expose as individual tools, or `all` for every operation. Setting this switches the connection to the individual-tool surface. An unknown slug returns `400 Bad Request`. | | `X-MCP-Tools` | Comma-separated allowlist of individual tool names (e.g. `list_rules,create_rule`). Also switches the connection to the individual-tool surface. Names that aren't individual tools — unknown names, or the `list_operations`/`describe_operation`/`call_operation` trio and `_Documentation` — return `400 Bad Request`. | | `X-MCP-Readonly` | Narrows this connection to read-only tools. Enabled by any value other than `false`, `0`, or empty; disabled by omitting the header (or setting it to `false` or `0`). Narrows only: if the workspace's MCP setting is read-only, this header cannot grant write access back — the workspace read/write setting is always the ceiling. | A toolset slug is an operation's group in lowercase, with underscores in place of spaces, so "Risk Engine" is `risk_engine`. The full set of toolset slugs: ```text administration, ai_chat, api_keys, approvals, audit, binary_uploads, blockables, dashboard, directory, event_export, events, execution_rules, file_access_rules, hosts, logs, mcp, mpa, network_flow_rules, package_rules, passkeys, reports, risk_engine, roles, rule_packs, signals, slack, sync_auth, sync_settings, tags, telemetry, webhooks ``` The server matches slugs exactly, so `Risk_Engine` fails. The 400 for an unknown slug names every valid one. `all` isn't a group. It selects every operation, more than 150 tools, and it beats any narrower slug: `toolsets=all,hosts` equals `toolsets=all`. Reach for it when you want per-tool approval and schemas rather than a smaller surface, since every definition ships in the client's tool list and costs context whether the model calls it or not. `X-MCP-Tools` takes tool names, not slugs: an operation's name in snake_case, so `ListRules` is `list_rules`. #### Configure a connection Individual-tool mode hides `list_operations`, so pick your groups and names on a default connection first: 1. Connect with no `X-MCP-*` config. 2. Call `list_operations`. Each entry's `group` becomes a slug, its `operation` becomes a snake_case tool name. 3. Reconnect with `X-MCP-Toolsets` or `X-MCP-Tools` set, alongside your existing auth. Or in the URL: ```text https://example.workshop.cloud/mcp?toolsets=hosts,execution_rules&readonly=true ``` Any MCP client that lets you attach custom HTTP headers to the connection can use these. The server reads the headers, so there is nothing to turn on in the client beyond passing them through. Clients that support custom headers on a remote HTTP MCP server include VS Code (GitHub Copilot), Cursor, Claude Code, Windsurf, Continue.dev, and LM Studio (see its `mcp.json` example above). The field name varies by client (Windsurf uses `serverUrl` for the URL, Claude Code takes a `--header` flag, and Continue.dev nests headers under `requestOptions`), so follow each client's own MCP config format. For clients that can only configure a bare URL — no custom headers — the same options are available as query params on the MCP endpoint: | Query param | Equivalent header | Example | | ----------- | ----------------- | ----------------------------------- | | `toolsets` | `X-MCP-Toolsets` | `/mcp?toolsets=risk_engine,hosts` | | `tools` | `X-MCP-Tools` | `/mcp?tools=list_rules,create_rule` | | `readonly` | `X-MCP-Readonly` | `/mcp?readonly=true` | Each query param takes the same comma-separated value and follows the same rules as its header. The two sources — headers and query params — are additive: their toolset and tool lists combine, and the connection is read-only if either source requests it. Prefer headers where you can set them, since query params can appear in proxy and server access logs; the tool and toolset names here aren't sensitive, and your credentials always travel in the `Authorization` header regardless. :::note Selecting either surface switches the connection to individual-tool mode, which hides the `list_operations`/`describe_operation`/`call_operation` trio — including `call_operation` — so a client can't dispatch an arbitrary operation through it and bypass the per-tool approval that individual tools exist to enable. Read-only tools declare an MCP `readOnlyHint` annotation, so HITL clients can use it to auto-approve reads. ::: ### Telemetry queries `QueryTelemetry` — exposed as `query_telemetry` on the individual-tool surface and as `call_operation("QueryTelemetry")` on the default one — streams results from the telemetry store and hands the model a single tool result. Three limits shape what comes back: - **Results truncate at 64 KB.** Rows are cut at a row boundary once the result reaches 64 KB. The result then carries `"truncated": true` and a `guidance` field telling the model to narrow the query with `LIMIT`, a time-range filter, or aggregation, or to reduce the column count when rows are wide. - **A call has 240 seconds on MCP and 90 seconds in AI chat.** A chat turn runs tool calls one after another inside a single request, so chat gets the smaller budget. Past the limit, a query that has produced rows returns them marked truncated, and one that has produced none returns a `deadline_exceeded` error. Both are self-correctable: narrow the query and try again. - **Keep-alives depend on the client.** While a slow query runs, the server emits an MCP `notifications/progress` message for each batch of rows and each 15-second heartbeat — but only if the client sent a `progressToken` with the tool call. A client that sends no token receives nothing until the query finishes, so its own tool-call timeout decides how long it waits. ### Example Prompts > Show me a summary of all rules in Workshop and use terms from the documentation to explain them. > Why is <app name> blocked on <host name> in Workshop? > Are any of my Workshop hosts out of date? > Are my Workshop hosts ready to switch from Monitor Mode to Lockdown Mode? --- ## API # API ## Quick Start The Workshop API uses [Connect RPC](https://connectrpc.com/) for [protobuf](https://protobuf.dev/)-based RPC. To make an API call, first generate an API key in the [API Keys](./api/api-keys) page. ### Using gRPC You can use [grpcurl](https://github.com/fullstorydev/grpcurl) to experiment with the API: ```sh $ grpcurl \ -d '{"uuid": "00000000-0000-0000-0000-000000000000"}' \ -H 'Authorization: npsws_sk_xxxxxxxxxxxxxx' \ example.workshop.cloud:443 \ workshop.v1.WorkshopService/GetHost ``` :::tip If you see an HTTP 464 error or "malformed header: missing HTTP content-type" when making gRPC requests, try adding `api.` to the beginning of your domain e.g. api.example.workshop.cloud ::: ### Using HTTP You can use HTTP clients like `curl` to make API calls. Refer to the [Connect documentation](https://connectrpc.com/docs/curl-and-other-clients) for more details. Mutable methods must be called with a `POST` request: ```sh $ curl \ -X POST \ -H 'Authorization: npsws_sk_xxxxxxxxxxxxxx' \ --json '{"uuid": "00000000-0000-0000-0000-000000000000"}' \ https://example.workshop.cloud/workshop.v1.WorkshopService/UpdateHost ``` Immutable methods can be called with a `GET` request: ```sh $ curl \ --get \ --data-urlencode 'encoding=json' \ --data-urlencode 'message={}' \ -H 'Authorization: npsws_sk_xxxxxxxxxxxxxx' \ https://example.workshop.cloud/workshop.v1.WorkshopService/ListAuditEvents ``` :::warning API methods that support `GET` will have the following option: ```proto option idempotency_level = NO_SIDE_EFFECTS; ``` ::: ## API Methods For available API methods, see the [workshop.proto reference](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService) or the [API Explorer →](./api/api-explorer) --- ## Approval Workflows # Approval Workflows A powerful feature of Workshop is the ability to delegate approvals decisions across your organization when running in Lockdown mode. When a user successfully completes an approval workflow they will receive allow rules for that specific application on their machine(s). This allows users to stay in Lockdown mode while still only running approved software. ## Conditions for an Approval Workflow to Begin - Santa has blocked an application from running on a user's system - The user is running in [Lockdown mode](https://northpole.dev/features/binary-authorization/#client-mode) and does not have an explicit rule allowing it - The user is part of a tag that has approval workflows configured - The binary or bundle have not been flagged as malware or confirmed as malware - If the Risk Engine is enabled then all configured plugins must return an _allow_ decision ### Risk Engine When the Risk Engine is configured and enabled, it will act as a gating function before it will let a user start an approval workflow. This allows admin users to set a threshhold for how risky something is and decide if admins want to let users approve for themselves or should seek expert assistance. ## Flagging an Application During an approval workflows each user and approver is presented with the option to flag an application as malicious via a button. This allows users to report malware that could otherwise be missed by the Risk Engine plugins or applications that are potentially troublesome. If they do flag an application as potentially malicious then all approvals workflows for that application are halted. Only after an admin has restored the state of the binary via the blockables page or APIs will approvals continue. ## Auditing All approval workflows create audit events showing the rules that were created and the target for those rules, and which approver approved, and at which time approval was granted. ### Audit Events All Audit Events can be seen here. - _BLOCKABLE_FLAGGED_MALICIOUS_ If a user or an approver flags a Binary or Application Bundle (aka Blockable) as malicious this event is created. - _VOTE_CAST_ When a user in a self-service or a social voting has voted to approve their software. This contains the voting user. - _SELF_SERVICE_RULE_CREATION_ This audit event is created when a user has approved their own software and a rule was created for their machines. - _SOCIAL_VOTING_RULE_CREATION_ This audit event is created when a user is in a social voting approval workflow and one of the threshholds has been met or exceeded. - _DESIGNATED_APPROVER_REQUEST_ This is created when a user is in a designated approver workflow and has asked for an application. - _DESIGNATED_APPROVER_REQUEST_APPROVED_ This is created when an approver approves a designated approver request for an application. - _DESIGNATED_APPROVER_REQUEST_REJECTED_ This is created when an approver rejects a designated approver request for an application. - _DESIGNATED_APPROVER_RULE_CREATION_ This is created when an approver approves a designated approver request for an application and rules are created. ## Supported Approval Workflows ### Self-Service Approval The simplest approval workflow is self-service approval. This allows any user in a tag to approve their own software provided it meets all of the conditions for starting an approval workflow. ### Designated Approver Workflows The designated approver workflows allow specific users to act as the approvers for users in a given tag. Approvers need not be admins themselves. All designated approver workflows (Manager Approval, Approval by Specified Users, Approval By Members of a Tag) optionally support requiring justification from the requestor while requesting approval for software. Requiring justification can be enabled in approval workflow settings. Justification is limited to 1000 characters. #### Manager Approval When configured to use manager approval. A user's manager must approve before rules are created for that user. If they approve. A user's manager is determined based on their directory sync attributes or via the API. A user's manager can also be seen in the Settings page in the users section at the bottom. #### Approval By Specified Users The second designated approver workflow is the specific users workflow. In this workflow only users on the approvers list may approve software. Users will need to interact with and ask only these approvers. Additionally admins may configure threshholds where multiple approvers can be required before approval is granted. #### Approval By Members of a Tag The third designated approver workflow is the specific users workflow. In this workflow only users who are members of a group mapped to a specific tag may approve software. This is useful if admins have rotations or otherwise use group membership to rotate who can approve software. Admins may configure threshholds where multiple approvers can be required before approval is granted. ## Social Voting In social voting, any user in the specified tags can vote to approve an application. Each vote counts towards the local and global threshholds. Once an application has met the local threshhold (must be 2 or greater) the users in the tag that have voted to approve will receive an allow rule. After the local threshhold has been met additional votes will add the allow rule to those users who have voted. Upon reaching the global threshhold an allow rule is pushed out for the tag. Additionally multiple tags can be joined to share votes. This allows different user populations to share approvals for common software will still allowing for other restricions to remain. Setting this approval workflow on the global tag provides a workflow similar to Google's Upvote the original Santa sync service. ## Configuring Approval Workflows All approval workflows can be configured via the UI as part of the tag settings. Additionally, approval workflows can be specified via the [UpdateApprovalWorklowSettings](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.UpdateApprovalWorkflowSettings) API --- ## Audit # Audit Every change made to Workshop, whether by UI or API, is recorded in the audit logging system. This provides a complete record of all actions taken in your Workshop deployment for security, compliance, and debugging purposes. ## Event Types Audit events are categorized by the type of resource or action being performed. Each event includes: - **ID**: A unique identifier for the event - **Transaction ID**: Links related events together (e.g., a vote that triggers rule creation) - **Timestamp**: When the event occurred - **Actor**: Who initiated the action (user, API key, host, or system) - **Event Type**: The specific action performed - **Resource**: The identifier of the affected resource - **Outcome**: Whether the action succeeded, failed, or was rejected - **Details**: Additional context about the event (often includes JSON data) - **Previous Value**: For update operations, the state before the change ### Example Event Types Here are some common audit events tracked by Workshop: **API Keys** - `APIKEY_CREATE`: A new API key was created - `APIKEY_DELETE`: An API key was deleted **Rules** - `RULE_UPSERT`: A rule was created or updated - `RULE_DELETE`: A rule was removed **Hosts** - `HOST_CREATE`: A new host registered with Workshop - `HOST_UPDATE`: Host information was modified - `HOST_SYNC`: Host synchronized with Workshop - `HOST_CLEAN_SYNC`: Host performed a clean sync (full rule refresh) - `HOST_MANUAL_PUSH`: Rules were manually pushed to a host **Tags** - `TAG_CREATE`: A new tag was created - `TAG_DELETE`: A tag was removed - `TAG_SET_ORDER`: Tag resolution order was changed **Settings** - `SETTINGS_UPDATE_SYNC_SETTINGS`: Santa sync settings were updated - `SETTINGS_TELEMETRY_CLOUD_BUCKET_UPDATE`: Telemetry export bucket configured - `APPROVAL_WORKFLOW_SETTINGS_UPDATE`: Approval workflow settings changed **Approval Workflows** - `SELF_SERVICE_RULE_CREATION`: User created a rule via self-service - `DESIGNATED_APPROVER_REQUEST`: Approval request was submitted - `DESIGNATED_APPROVER_REQUEST_APPROVE`: Request was approved - `DESIGNATED_APPROVER_REQUEST_REJECT`: Request was rejected - `VOTE_CAST`: A vote was cast on a blockable **Risk Engine** - `RISK_ENGINE_EXCEPTION_CREATE`: A risk engine exception was created - `BLOCKABLE_FLAG_MALICIOUS`: A blockable was flagged as malicious ## Viewing Audit Events ### Accessing the Audit Log Navigate to the Audit Log under **Settings** in the Workshop UI to view all audit events. The audit table provides: - **Filtering**: Search and filter by event type, actor, resource, outcome, and date range - **Sorting**: Sort by timestamp, event type, or outcome - **Expandable Rows**: Click any row to see full event details including JSON diffs for updates - **Transaction Linking**: Click a transaction ID to view all related events ### Querying Examples **Filter by event type:** Use the event type filter to show only specific types of events, such as all rule changes or host syncs. **Filter by actor:** Find all actions performed by a specific user, API key, or host by filtering on the actor field. **Filter by date range:** Select a date range to view events within a specific time period. **View related events:** Click on a transaction ID to see all events that are part of the same transaction. This is useful for tracking complex operations like approval workflows that generate multiple audit events. ### Event Details When you expand an audit event row, you'll see: - Complete event metadata (ID, transaction ID, timestamp, actor) - The full resource identifier - Detailed information about what changed - For update operations, a side-by-side diff showing before and after values ## Audit Log Export Workshop can automatically export audit logs to cloud storage for long-term retention, compliance requirements, or integration with external SIEM systems. See the [Event Export documentation](./event-export) for detailed information on configuring audit event export, including cloud storage setup and export behavior. --- ## Binary Upload # Binary Upload Workshop can pull a copy of a binary off an enrolled host and store it in your own cloud storage bucket. Use it to grab a sample for analysis, hunt across past activity, or keep a file before it is deleted. Santa reads the file on the host and uploads it straight to your bucket. The bytes never pass through Workshop. :::info Binary upload is off until you set it up. You need a storage bucket connected to Workshop, and — if you restrict Santa's commands — `binary_upload` allowed for your hosts. See [Setup](#setup). ::: ## Requesting an upload Request an upload from a binary's event details or the API. You can target a **path** or a **SHA-256**. Santa finds binaries by path, so a request always resolves to a path on the host. Uploading by SHA-256 works only when the host already executed that file, which lets Workshop map the hash to a path. If it cannot, upload by absolute path instead. ## Where uploaded files are stored When Workshop knows the file's SHA-256, it stores the object under that hash at the root of your bucket. The same file collected from many hosts lands at one key, so you keep a single copy. When you upload by path and Workshop has no record of that file's hash on the host, it cannot name the object ahead of time. Workshop stores the file under a unique key beginning with `operator_uploads/`, and Santa reports the SHA-256 it computed during the upload. These objects are not named by content, so the same file collected twice produces two objects. See [Bucket Setup](/binary-upload/bucket-setup) to connect a bucket and run the Test Bucket check. ## Filtering what gets uploaded Santa can skip an upload on the host before any bytes leave the machine. You write CEL expressions against the binary's metadata, and a match means the file is not uploaded. Set them with the [`BinaryUploadFilterExpressions`](https://northpole.dev/configuration/keys/#BinaryUploadFilterExpressions) key in the Santa configuration profile (delivered through your MDM). | Goal | Expression | | -------------------------------------- | ------------------------------------------------ | | Skip Apple and other platform binaries | `binary.is_platform_binary` | | Skip code from a Team ID you trust | `binary.team_id == "ABCDE12345"` | | Skip a vendor's signed apps | `binary.signing_id.startsWith("com.microsoft.")` | | Skip files over 200 MB | `binary.file_size > 200000000` | | Skip dynamic libraries | `binary.macho_type == "dylib"` | See [Filter Expressions](/binary-upload/filter-expressions) for every field and the full behavior. ## Setup 1. Connect an `s3://` or `gs://` bucket in Workshop and run **Test Bucket**. See [Bucket Setup](/binary-upload/bucket-setup). 2. If you restrict Santa's commands with the [`AllowedSantaCommands`](https://northpole.dev/configuration/keys/#AllowedSantaCommands) key, add `binary_upload` to the list — otherwise those requests are rejected. When the key is not set, every command is allowed and no change is needed. 3. Optionally add [filter expressions](/binary-upload/filter-expressions) to exclude binaries you do not want. :::note Binary upload uses the same push channel as Santa's other host commands, so hosts need push notifications enabled to receive requests. ::: ## Results | Disposition | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------------------------- | | `COMPLETED` | The file was uploaded. Its SHA-256 and byte count are returned. | | `REFUSED` | The host declined. A filter expression matched, the file is not a Mach-O image, or uploads are turned off locally. | | `NOT_FOUND` | No regular file exists at the resolved path. | | `HASH_MISMATCH` | The file did not match the expected SHA-256. The upload stopped before anything was stored. | | `HTTP_ERROR` | The bucket rejected the upload. The status and a short snippet are in the message, usually a bucket policy problem. | | `INTERNAL_ERROR` | Santa could not open, hash, or stream the file. | ## See Also - [Bucket Setup](/binary-upload/bucket-setup) - [Filter Expressions](/binary-upload/filter-expressions) - [Telemetry](/telemetry/) --- ## Event Export # Event Export Workshop can automatically export events to cloud storage for long-term retention, compliance requirements, or integration with external SIEM systems and analytics platforms. ## Supported Event Types Workshop supports exporting the following types of events: - **Audit Events**: All changes made to Workshop (rules, settings, tags, etc.) - **Execution Events**: Santa execution events from macOS endpoints - **File Access Events**: Santa file access monitoring events from macOS endpoints - **Network Events**: Santa network flow events from macOS endpoints - **Removable Media Events**: Santa removable media (e.g. USB mass storage device) mount events from macOS endpoints - **Network Mount Events**: Santa network mount events from macOS endpoints - **Host Metrics**: The latest CPU and memory reading Workshop holds for each endpoint Each event type can be configured independently with its own cloud storage bucket. ## Configuring Event Export Navigate to Settings → Event Export to configure export settings for each event type. ### Audit Event Export Audit events track all changes made to Workshop, whether by UI or API. Exporting audit logs provides a complete record of all actions for security, compliance, and debugging purposes. **To configure:** 1. Navigate to the Audit Events section 2. Enter your cloud storage bucket URL in one of these formats: - AWS S3: `s3://your-bucket-name` - Google Cloud Storage: `gs://your-bucket-name` 3. Click **Save Changes** See the [Audit documentation](./audit) for more details on audit event types and viewing audit logs. ### Execution Event Export Execution events record binary executions detected by Santa on your macOS endpoints. This includes allowed and blocked executions, along with binary metadata and host information. Executions allowed because the binary is an Apple platform binary are not exported by default. Workshop keeps only their aggregate counts, so there are no individual events to export. To store and export them for a set of hosts, enable **Platform binary events** in the **Sync** section of a tag's settings — see [Platform binaries](/events#platform-binaries). **To configure:** 1. Navigate to the Execution Events section 2. Enter your cloud storage bucket URL 3. Click **Save Changes** Execution events include details such as: - Binary SHA-256, file path, and signing information - Execution decision (allowed, blocked, or blocked by bundle) - Host information (hostname, primary user, OS version) - Process information (PID, PPID, executing user) - Tags applied to the host at execution time ### File Access Event Export File access events record Santa's file access monitoring activity, which tracks access to protected paths on your endpoints. **To configure:** 1. Navigate to the File Access Events section 2. Enter your cloud storage bucket URL 3. Click **Save Changes** File access events include: - Accessed file path and details - Access type and decision - Process information for the accessing application - Host and user information ### Network Event Export Network events record the network flows observed and evaluated by Santa's network extension on your endpoints. **To configure:** 1. Navigate to the Network Events section 2. Enter your cloud storage bucket URL 3. Click **Save Changes** Network events include: - Remote and local address and port, protocol, and direction - Resolved hostname for the flow - Flow decision, decision tier, and the rule that matched - Process information for the process that made the connection - Host and user information ### Removable Media Event Export Removable media event record Santa's mount monitoring and blocking activity for things like USB devices. **To configure:** 1. Navigate to the Removable Media Events section 2. Enter your cloud storage bucket URL 3. Click **Save Changes** Removable media events include: - Device details like protocol, model and vendor - Mount path and decision - Remount arguments if configured - Host and user information ### Network Mount Event Export Network mount event record Santa's network filesystem mount monitoring and blocking activity. **To configure:** 1. Navigate to the Network Mount Events section 2. Enter your cloud storage bucket URL 3. Click **Save Changes** Network mount events include: - Mount path from and mount path on - Filesystem type - Host and user information ### Host Metrics Export Santa reports on its own CPU and memory use as it syncs. Workshop keeps the latest reading for each host, and each export writes the current reading of every host, one record per host. This is a point-in-time picture of the fleet rather than a stream of events, so consecutive exports form the time series. **To configure:** 1. Navigate to the Host Metrics section 2. Enter your cloud storage bucket URL 3. Click **Save Changes** Host metrics records include: - Host and user information - When Workshop last updated the reading - CPU used in user and in system mode, as a percentage of one core - Resident memory of the Santa daemon, in bytes A percentage is measured between a host's two most recent submissions, so it is absent until a host has submitted twice. Any value the host has not reported is left out of the record rather than written as zero. ## Cloud Storage Access The Workshop service account must have read/write access to the specified buckets. Both AWS S3 and Google Cloud Storage are supported. You can provide access to your bucket to the Workshop service role using a bucket policy like the one below, replacing `<123456789123>` and `` appropriately: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::<123456789123>:role/WorkshopTaskExecutionRole" }, "Action": ["s3:PutObject"], "Resource": ["arn:aws:s3:::/*"] } ] } ``` In the Google Cloud console, you can give the necessary access to the Workshop service account principal with the "Storage Object Creator" role. You can also do this with the gcloud CLI, replacing `` and `` as appropriate: ```shell gcloud storage buckets add-iam-policy-binding gs:// \ --member=serviceAccount: \ --role=roles/storage.objectCreator ``` :::tip You can use the same bucket for every event type, or configure separate buckets for organizational purposes. Events are written to different file paths based on type. ::: ## Export Behavior ### Scheduling - Events are exported periodically - Audit events are exported once per hour - All other event types are exported every 10 minutes - Each export batch is limited to 25,000 records - If more events are available after a batch, export continues automatically until fewer than 1,000 events remain - Exports run independently for each event type ### Initial Export - If you have a large number of existing events, the initial export after configuration may take some time to complete as it works through the backlog in batches - The export process will gradually work through historical events until it's caught up ### Progress Tracking - The export process tracks the last exported event ID for each event type - You can view the last exported event ID in the Settings page to monitor export progress - Click on the event ID to view that specific event in Workshop - Host metrics are a full dump with no per-record cursor, so the Settings page shows when the last export ran instead - Export resumes automatically from the last checkpoint if interrupted ### Data Format - Events are written as newline-delimited JSON (NDJSON) files - Each line in the exported files is a complete JSON object representing one event - Files are organized by event type and timestamp - All event fields are included in the export (IDs, timestamps, metadata, details, etc.) --- ## Events # Events The Events interface provides visibility into events across your Santa-protected fleet. This feature lets administrators track, analyze, and respond to what Santa saw and decided throughout your organization. ## Events vs. telemetry Workshop collects endpoint data in two separate streams, and they are easy to mix up. The short version: events tell you what to allow, telemetry tells you what happened. ### Events An event is something Santa decided and thought you should know about. Executions are only part of it. The Events section has a tab for each kind: | Tab | What it holds | Rules it informs | | ------------------- | --------------------------------------------------------- | --------------------------------------------- | | **Execution** | Binary execution attempts and the allow or block decision | [Execution Rules](/rules/execution-rules) | | **File Access** | Reads and writes against paths you have chosen to watch | [File Access Rules](/rules/file-access-rules) | | **Network** | Network flows seen by Santa's network extension | [Network Rules](/rules/network-rules) | | **Removable Media** | USB and SD card mounts | Removable media settings | | **Network Mount** | SMB, NFS and other network volume mounts | Network mount settings | | **Signals** | Reports from your own CEL detection rules | Signal definitions | Whatever the kind, the shape is the same. An event is a small record built around the identifiers you need to write or change a rule: CDHash, binary SHA-256, Signing ID, Team ID, certificate, path, remote peer. Workshop stores them and you search them from this console. [Rules](/rules), [Approval Workflows](/approval-workflows) and the [Risk Engine](/risk-engine) all run off them. [Event Export](/event-export) pushes them into a bucket of your own. Execution events arrive out of the box. The rest appear only after you set up a policy that watches for them. An empty File Access or Network tab means nothing is configured, not that nothing is happening. Signals are the odd one out. A signal is a CEL detection rule that runs on the host against telemetry. A match produces a signal report, and the report lands here in Events. Signals need telemetry enabled, so they are the one place where the two streams meet. ### Telemetry Telemetry is the raw record of what the machine did, with no policy applied. It covers forks and exits, every file write and rename, logins, SSH sessions, TCC changes, disk mounts, launch items and network activity. Executions are in there too, whether or not Santa had an opinion about them. Each record holds more than an event does: the full argument vector, environment variables, entitlements, the parent process chain, the whole certificate chain. Telemetry lands in your own cloud storage bucket and you query it with SQL. Use it when something has already happened and you need to reconstruct it. ### How often Santa uploads an event Santa doesn't upload an event for every execution. There would be far too many, so it drops repeats on the host. The throttling changes what the numbers in this console mean. **Blocks are never throttled.** When Santa stops an execution, it sends the event straight away instead of waiting for the next sync. Blocked file access events work the same way. **Allowed executions are filtered twice.** First, most of them never become an event. By default Santa records an allowed execution only when the binary was unknown, which is what happens in Monitor mode, or when an audit rule matched. An execution that matched a rule you already wrote records nothing. You already know about it. Set `EnableAllEventUpload` if you want every allow recorded. Second, whatever is left is throttled to **one event per binary per host every 4 hours**. Santa keeps an in-memory cache keyed on the binary's SHA-256. A repeat inside that window is dropped, not queued. A tool that runs a thousand times a day produces at most six events on that host. Restarting the Santa daemon clears the cache. Allowed events don't go up straight away either. They wait in the host's event table for the next sync, which runs every 10 minutes by default. Other event types have their own windows: | Event | How often it can be uploaded | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | Execution, blocked | Every time, sent straight away | | Execution, allowed | Once per binary SHA-256 per host per 4 hours, and only for unknown binaries unless `EnableAllEventUpload` is set | | File access, denied | Every time | | File access, audit-only allow | Once per rule and process SHA-256 per host per 4 hours, whichever path was touched | | Network flow | Once per process and rule per host per 10 minutes, allowed or denied | | Removable media and network mounts | Once per mount per host per 10 minutes | | Signals | Once per signal name per host per 10 minutes | :::warning Event counts are not execution counts Counting rows in the events table won't tell you how often a binary ran. Use telemetry, the aggregate counts in [Reports](/reports), or the binary's own execution history. ::: Apple platform binaries produce no individual events unless you ask for them. They are the highest-volume decision, so Workshop keeps aggregate counts only. Those counts show up in reports, dashboard charts and a binary's execution history. See [Platform binaries](#platform-binaries) to store each one instead. Telemetry has none of these windows. Every occurrence of every event is recorded. The only thing that removes records is you. [Filter Expressions](/telemetry/filter-expressions) drop or redact events on the host before upload, so if you configure any, your bucket holds what survived them. ### Which one to use | | Events | Telemetry | | --------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | **Answers** | Should this be allowed? | What happened here? | | **Covers** | Executions, file access, network flows, mounts, signal reports | Everything Santa can see | | **Completeness** | Every block, allowed executions at most once per 4 hours | Everything, no throttling, minus anything your [Filter Expressions](/telemetry/filter-expressions) drop | | **Detail per record** | Enough to write a rule | Process, signing, file and network detail | | **Lives in** | Workshop | Your own cloud storage bucket | | **Query it with** | The events tables and [filter expressions](/filter-language) | SQL, see [Telemetry](/telemetry) | | **Availability** | Executions always on, the rest follow your policy | Off by default, ask support to turn it on | | **Good for** | Writing rules, approvals, triaging blocks | Incident response, threat hunting, audits | Most work uses both. A block shows up in Events, you look at the binary, you write a rule. Later you want to know what else that process touched, or which other hosts saw it. That's telemetry. To cut telemetry volume before it leaves the host, see [Filter Expressions](/telemetry/filter-expressions). ## Execution events The execution events table displays information about each execution event: - **Host Name**: The name of the endpoint where the event occurred - **File Name**: The name of the executed binary - **Decision**: Whether the execution was allowed or blocked - **Reason**: The reason for the decision - **Timestamp**: When the execution attempt occurred ### Platform binaries {#platform-binaries} Executions that Santa allowed because the binary ships with the operating system are the highest-volume decision by a wide margin. By default Workshop records only their aggregate counts and does not retain or export the individual events. To store each one instead, enable **Platform binary events** in the **Sync** section of a tag's settings. The setting is per tag, so you can turn it on for a subset of your fleet. Expect a large increase in stored events: turn it on when you need platform binary executions to be searchable, filterable and exportable, and scope it to the hosts that need it. Once stored, the events appear in the execution events table with a **Reason** of **Platform**. Filter the table by that reason to see them on their own. The aggregate counts are recorded either way, so turning the setting on or off never changes the numbers in reports or charts. It only changes whether the individual events are kept. A change can take up to 5 minutes to take effect on each host, because Workshop caches the resolved setting per host while it processes a host's uploads. ## Event details Click a file name in the events table to open a detailed view with additional information about the binary: - **SHA-256**: The cryptographic hash of the binary - **CDHash**: The code directory hash used by macOS for code signing verification - **Team ID**: The Apple Developer Team ID associated with the binary - **Signing ID**: The signing identity used to sign the binary - **Entitlements**: A list of entitlements granted to the binary - **First Seen**: When this binary was first observed in your environment ### Creating rules from event details From the event details page, you can create execution rules directly using the **Create Rule** dropdown. The dropdown lets you pick which identifier type to use (CDHash, Binary SHA-256, Signing ID, Certificate SHA-256, or Team ID) based on the event's binary. :::tip Hold the **Option** key (macOS) or **Alt** key (Windows/Linux) while clicking a menu item to automatically scope the new rule to the host that generated the event. The dropdown shows a "Scoped to this host" indicator when the key is held. ::: --- ## Filter Language # Filter Language Most Workshop API methods that begin with `List` (e.g., `ListHosts`, `ListRules`, `ListEvents`) support filtering results using a standardized filter language. ## Overview The filter system is: - **Generic**: Works across all API types that support filtering - **Type-safe**: Validates field names and types against the protobuf schema at runtime - **AIP-160 compliant**: Based on [Google's AIP-160 filtering standard](https://google.aip.dev/160) Filters are provided as a string in the `filter` field of List request messages: ```protobuf message ListHostsRequest { int32 page_size = 1; int32 page = 2; string filter = 3; // Filter expression goes here string order_by = 4; } ``` The _fields_ available for filtering will match those in the type returned in the response message. For example, the `ListHostsResponse` message looks like this: ```protobuf message ListHostsResponse { repeated Host hosts = 1; optional bool more = 2; } ``` The fields available for filtering are those on the [`Host`](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.Host) message: ```protobuf message Host { string uuid = 1; string serial = 2; string machine_model = 3; // remaining fields elided for brevity } ``` ## Basic Syntax ### Simple Equality ``` hostname = 'example-host' ``` ### Comparison Operators ``` last_seen_client_mode > 1 last_seen_client_mode >= 1 last_seen_client_mode < 3 last_seen_client_mode <= 3 last_seen_client_mode != 2 ``` ### String Pattern Matching (Case-Insensitive) The `:` operator performs a case-insensitive pattern match (SQL `ILIKE`): ``` hostname:'r%' # Hostnames starting with 'r' hostname:'%dev%' # Hostnames containing 'dev' ``` ## Boolean Logic ### AND Operator ``` hostname = 'homer' AND last_seen_client_mode > 1 ``` ### OR Operator ``` hostname = 'homer' OR hostname = 'marge' ``` ### NOT Operator ``` NOT (hostname = 'homer') ``` ### Operator Precedence AND has higher precedence than OR. Use parentheses to control evaluation order: ``` tags_locked = true AND hostname = 'homer' OR hostname = 'marge' # Evaluates as: (tags_locked = true AND hostname = 'homer') OR (hostname = 'marge') ``` ## Special Values ### Boolean Literals ``` tags_locked = true tags_locked = TRUE # Case-insensitive tags_locked = false tags_locked = FALSE ``` ### NULL Values ``` hostname = NULL # Becomes: hostname IS NULL hostname != NULL # Becomes: hostname IS NOT NULL ``` ### Timestamp Fields Timestamp fields can be compared using Unix timestamps (seconds since epoch): ``` rule_sync_time > 946688400 # After 2000-01-01 01:00:00 UTC ``` ### Current Time Use `NOW()` to get the current timestamp: ``` rule_sync_time > NOW() # Rule sync time is in the future ``` ### Time Arithmetic Use `ADD()` and `SUB()` to perform arithmetic on timestamps: ``` rule_sync_time > SUB(NOW(), 3600) # Within the last hour (3600 seconds) rule_sync_time > SUB(NOW(), 86400) # Within the last 24 hours rule_sync_time < ADD(NOW(), 86400) # Within the next 24 hours ``` These functions take two integer arguments and return the result: | Function | Description | Example | | ----------- | ------------- | ------------------ | | `ADD(a, b)` | Returns a + b | `ADD(NOW(), 3600)` | | `SUB(a, b)` | Returns a - b | `SUB(NOW(), 3600)` | Both arguments must be numeric values (not field references). ## Enum Fields Enum fields can be queried by their string identifier or numeric value: ``` last_seen_client_mode = 'MONITOR' # Using enum identifier last_seen_client_mode = 1 # Using numeric value ``` The filter system validates enum identifiers against the protobuf enum definition at runtime. ## Repeated Fields ### IN Operator with Repeated Fields Check if a value exists within a repeated field: ``` IN('dev', tags) # Check if 'dev' is in the tags array IN('prod', tags) # Check if 'prod' is in the tags array ``` ### IN Operator with Multiple Values Check if a field matches any value in a list: ``` IN(hostname, 'homer', 'marge', 'bart') IN(last_seen_client_mode, 1, 2) IN(last_seen_client_mode, 'LOCKDOWN', 'STANDALONE') ``` ### Counting Array Elements Use `.count` to get the number of elements in a repeated field: ``` tags.count > 0 # Has at least one tag tags.count = 3 # Has exactly 3 tags tags.count < 5 # Has fewer than 5 tags ``` ## Nested Fields For message types that contain nested messages, use dot notation: ``` host.hostname = 'kvothe' host.last_seen_client_mode = 'MONITOR' ``` Example with `IN()` on nested fields: ``` IN(host.hostname, 'kvothe', 'bast') ``` ## Complex Examples ### Multiple Conditions ``` hostname:web% AND tags.count > 0 AND last_seen_client_mode = 'MONITOR' ``` ### Combining OR and AND ``` (hostname = 'web-1' OR hostname = 'web-2') AND tags_locked = false ``` ### Checking for Tagged Production Hosts ``` IN('prod', tags) AND hostname:prod-% ``` ## Type Safety The filter system validates: 1. **Field existence**: Referenced fields must exist in the protobuf message 2. **Field types**: Argument types must match the field type 3. **Enum values**: String enum identifiers must be valid for the enum type 4. **Repeated field operations**: `.count` can only be used on repeated fields ### Common Type Errors ``` # ERROR: Type mismatch (string vs int64) hostname = 42 # ERROR: Type mismatch (enum vs float64) last_seen_client_mode = 3.14 # ERROR: Type mismatch (bool vs int64) tags_locked = 1 # ERROR: Invalid enum identifier last_seen_client_mode = 'INVALID_MODE' # ERROR: .count on non-repeated field hostname.count > 1 ``` ## Error Messages When a filter fails validation, you'll receive an error describing the problem: ``` field "MissingField" referenced in filter does not exist or is not exported ``` ``` type of argument 42 (int64) in filter isn't convertible to type of field "hostname" (StringKind) ``` ``` argument FLARGLE is not valid for the enum santa.sync.v1.ClientMode ``` ## See Also - [AIP-160: Filtering](https://google.aip.dev/160) - Google's API Improvement Proposal for filtering --- ## Hosts # Hosts The Hosts interface provides a comprehensive view of all endpoints running Santa across your organization. This centralized dashboard allows administrators to monitor, manage, and troubleshoot Santa deployments at scale. ## Overview The Hosts dashboard displays information about each endpoint: - **Hostname**: The hostname of the endpoint - **Mode**: Current mode of the Santa agent ([learn more about modes](https://northpole.dev/concepts/mode.html)) - **OS Version**: Operating system version information with indicators for outdated versions - **Santa Version**: Version of the Santa agent - **Last Sync**: Time of last communication with the sync server ## Host Details Clicking on an individual host provides detailed information: - **Host Details**: Hostname, UUID, primary user, hardware model, serial number, OS version/build, and Santa version with update indicators - **Client Mode**: Current enforcement mode (Monitor, Lockdown, or Standalone) with ability to change settings - **Sync Information**: Last sync time, rule sync time, last preflight and postflight times - **Sync Controls**: Options to trigger immediate sync or perform a clean sync - **OS Updates**: Indicators when updates are available for the operating system - **Santa Updates**: Indicators when newer Santa versions are available ## Changing Client Modes To change the mode of a host, click the "Change Mode" button and select the desired mode. :::warning Changing the mode of a host will immediately impact the behavior of the Santa agent on that host. This can cause disruption to users if not done carefully. ::: ## Syncing Hosts Hosts can be synced manually or automatically. - **Manual Sync**: Click the "Sync Now" button to force a sync of the host - **Clean Sync**: Click the "Clean Sync" button to force a clean sync of the host. :::warning A clean sync will take a long time to complete and should only be done when necessary. ::: ## Machine ID Changes If your organization changes the format used for machine IDs (e.g. using the `MachineID` key in Santa), Workshop will attempt to match the new machine ID to an existing host record. If a machine syncs with a new ID but has the same hardware model identifier, serial number, and primary user as an existing host, the existing record will be updated with the new ID rather than creating a duplicate entry. This avoids double-counting hosts in most cases. However, if any of those fields differ — for example, if the primary user changed at the same time as the machine ID — Workshop will treat the endpoint as a new host. The old host entry will remain visible for 30 days before it is automatically removed. During this window the same physical machine will be counted twice. ## Deleting Hosts Hosts can be manually deleted. This will immediately delete all data about this host from the database but will leave events for that host in the events table. --- ## Linux Telemetry Schema {/* Code generated from the telemetry schema by github.com/northpolesec/sleigh/cmd/schemadoc. DO NOT EDIT. */} # Linux Telemetry Schema This page documents the complete schema for all telemetry event types collected by Workshop from Santa agents on Linux. :::info Linux telemetry is in beta. Both the set of events and the fields on them may still change. ::: Each event type below is also the table name in SQL queries, prefixed with `linux_`: exec fields live in `linux_exec_2026`, `linux_exec_202603`, or `linux_exec_20260315` (see [table naming](/telemetry#table-naming-convention)). The prefix exists because `fork` and `exit` are collected on macOS too, with entirely different columns. Columns drift across Santa versions, so `DESCRIBE linux_exec_20260315` on your own data is the authoritative list. Linux telemetry is not shared with the [macOS schema](/telemetry/schema/macos), including the base fields. A telemetry field with the same name on both platforms does not necessarily mean the same thing, and a query written against one platform will not run against the other. Package inventory is the exception: its platform-independent `packages` table has the same columns on every host. ## Contents **Event tables** - Process Events: [linux_fork](#linux_fork), [linux_exec](#linux_exec), [linux_exit](#linux_exit) - Inventory Events: [packages](#packages) **Common types** [Credentials](#credentials), [Namespaces](#namespaces), [Cgroup](#cgroup), [Process](#process), [Lineage](#lineage), [Filesystem](#filesystem), [FileIdentity](#fileidentity), [Digest](#digest), [File](#file), [FileDescriptor](#filedescriptor), [Tty](#tty), [Image](#image), [WorkloadOwner](#workloadowner), [KubernetesPod](#kubernetespod), [Container](#container) ## Base Fields Every Linux telemetry table carries these identity and timing columns. | Field | Type | Description | | --------------- | --------- | -------------------------------------------------------------------- | | EventID | text | Unique identifier for the event | | MachineID | text | The unique machine ID (host UUID) | | Hostname | text | The hostname of the machine at the time of the event | | BootID | text | The kernel's boot identifier, stable for the lifetime of one boot | | EventTime | timestamp | When the event occurred | | ProcessedTime | timestamp | When Workshop processed the event | | OperatingSystem | text | The platform the telemetry came from, always `linux` in these tables | ## Process Events ### linux_fork A process creating another process. `SharedThreadGroup` is what separates a new thread of the same program from a genuinely independent process, and it decides that on its own. `SharedMm` and `SharedFiles` each report one resource shared independently of that: a thread commonly shares both, but neither is required for thread-group membership, and either can be shared by a task that is a separate process. | Field | Type | Description | | ----------------- | ----------------------- | ---------------------------------------------------------------------------------------------- | | Subject | [Lineage](#lineage) | The process performing the creation, with the process that started it | | Object | [Process](#process) | The newly created process | | SharedThreadGroup | boolean | Whether the new task joined its creator's thread group, making it a thread of the same program | | SharedMm | boolean | Whether the new task shares its creator's address space | | SharedFiles | boolean | Whether the new task shares its creator's file descriptor table | | Container | [Container](#container) | The container the subject ran in. Absent when it ran directly on the host | ### linux_exec A process replacing itself with a new program. | Field | Type | Description | | ----------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Subject | [Lineage](#lineage) | The process performing the execution and the process that started it, as they are after the change took effect | | Comm | text | The short name the new program will be known by, at most 16 characters. Chosen by the program, so not evidence of what ran | | Executable | [File](#file) | The canonical path of the executable image that actually ran | | Invoked | [File](#file) | What was asked for. For a script this is the script, where `Executable` is the interpreter | | Interpreted | boolean | Whether `Invoked` and `Executable` differ because an interpreter handled the request | | WorkingDirectory | [File](#file) | The working directory, needed to make sense of any relative path in `Args`. Only `Path` and `Type` are populated | | Root | [File](#file) | The process's root directory, which is how a process confined to a subtree becomes visible. Only `Path` and `Type` are populated | | FileDescriptors | [FileDescriptor](#filedescriptor) array | The standard descriptors the program inherited | | Tty | [Tty](#tty) | The terminal the process is attached to | | Args | text array | Command-line arguments | | Envs | text array | Environment variables | | Argc | number | An upper bound on how many entries `Args` holds, not a guarantee | | ArgvEnvpCopied | number | Bytes of arguments and environment captured. Zero when capture failed outright | | ArgvEnvpTruncated | boolean | When set, the absence of an expected argument means nothing | | Container | [Container](#container) | The container the subject ran in. Absent when it ran directly on the host | ### linux_exit A process terminating. `Signal` is meaningful only when `ExitFromSignal` is set, and `ExitStatus` only when it is not. `ExitCode` is retained so nothing is lost, but it cannot be interpreted without `ExitFromSignal`; read the decoded fields instead. | Field | Type | Description | | -------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Subject | [Lineage](#lineage) | The terminating process, with the process that started it | | ExitCode | number | The raw packed status value a waiting parent receives. Cannot be interpreted without `ExitFromSignal`; read `Signal` and `ExitStatus` instead | | ExitFromSignal | boolean | Whether the process was killed by a signal rather than ending on its own | | Signal | number | The signal that killed the process, meaningful only when `ExitFromSignal` is set | | ExitStatus | number | The status the process chose to end with, meaningful only when `ExitFromSignal` is not set | | CoreDumped | boolean | Whether a memory dump was started, not that one was completed or written anywhere | | GroupDead | boolean | Whether a whole program ended, as opposed to one thread of a still-running program | | Container | [Container](#container) | The container the subject ran in. Absent when it ran directly on the host | ## Inventory Events Unlike every other table on this page, inventory tables are not populated by the continuous telemetry stream. They are produced on demand by the Package Inventory command (**Hosts → Commands → Run command**), which asks each targeted host to run a read-only scan and upload the results into its normal telemetry prefix. A host that has never been scanned has no rows. Two consequences worth knowing when querying: - **`BootSessionUUID` is always empty.** An on-demand scan isn't tied to a boot session. - **`EventTime` is the scan time**, not the time a package was installed — the scan observes current state and has no visibility into when it came to be. ### packages One row per package discovered on a host. Every ecosystem shares this single table, distinguished by `Ecosystem`, so a fleet-wide query needs no unions. | Field | Type | Description | | ------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | EventID | text | Unique identifier for the inventory record | | MachineID | text | The unique machine ID (host UUID) | | Hostname | text | The hostname of the scanned host | | BootSessionUUID | text | Always empty because an inventory scan is not tied to a boot session | | EventTime | timestamp | When the inventory scan observed the package | | ProcessedTime | timestamp | When Workshop processed the inventory record | | OperatingSystem | text | The platform the scanned host is running | | RecordType | text | Always `package` in this table | | RunID | text | Identifier shared by every row from one scan; use it to isolate a single scan's results | | Profile | text | Scan profile that produced the row: `baseline`, `project`, or `deep` | | Ecosystem | text | `npm`, `pypi`, `go`, `rubygems`, `packagist`, `mcp`, `editor-extension`, `browser-extension`, `homebrew`, `agent-skill`, or `nix` | | PackageName | text | Package name as written in the manifest or lock file | | NormalizedName | text | Ecosystem-normalized name — join on this rather than `PackageName` | | Version | text | Installed version. Empty when no exact version could be determined | | ProjectPath | text | Root of the project the package belongs to, for project-scoped finds | | RootKind | text | Why the containing directory was walked: `global_package_root`, `user_package_root`, `project_root`, `editor_extension_root`, `browser_extension_root`, `mcp_config_root`, `homebrew_root`, `agent_skill_root`, `deep_home_root`, or `unknown` | | InstallScope | text | Ecosystem-specific dependency scope (e.g. `prod`/`dev` for npm and pnpm, `indirect` for Go modules) | | PackageManager | text | Manager that installed the package (e.g. `npm`, `pnpm`, `homebrew`, `firefox-extension`) | | SourceType | text | Kind of evidence the row came from (e.g. `package.json`, `browser-extension`) | | SourceFile | text | Path to the manifest, lock file, or metadata file the row was read from | | DirectDependency | boolean | Whether the package is directly depended on rather than transitive. Null when the ecosystem can't distinguish | | HasLifecycleScripts | boolean | Whether the package declares install-time lifecycle scripts — these execute on install, so they are a supply-chain execution surface | | LifecycleScripts | text array | Names of the declared lifecycle scripts | | Confidence | text | How certain the identification is: `high`, or `medium` when the name or version had to be inferred | | RequestedSpec | text | For MCP entries configured by spec, the requested selector (e.g. `@playwright/mcp@latest`) with `PackageName` normalized to the bare name | | LocalAlias | text | Local name assigned in a config file, where that differs from the package it resolves to. Set only for `mcp` (the key under `mcpServers`) and `agent-skill` (the local skill name) | ## Common Nested Types The following types are used throughout the Linux telemetry schema to represent shared data structures. ### Credentials The identity a process runs as. Two sets are carried on every process: the real set records who started it, the effective set is what permission checks are made against, so a program that has changed identity is still attributable to whoever launched it. | Field | Type | Description | | -------------- | ------ | ----------------------------------------------------------------------- | | UID | number | The real owner of the process, and who is able to signal it | | GID | number | The real group of the process | | SUID | number | Saved user ID, which a process that has dropped privilege can return to | | SGID | number | Saved group ID, the group equivalent of `SUID` | | EUID | number | The process's privileges for most non-filesystem access | | EGID | number | The group equivalent of `EUID` | | FSUID | number | The identity used when accessing filesystem objects | | FSGID | number | The group equivalent of `FSUID` | | CapInheritable | number | Inheritable capability set, as a bitmask | | CapPermitted | number | Permitted capability set, as a bitmask | | CapEffective | number | Effective capability set, as a bitmask | | CapBounding | number | Bounding capability set, as a bitmask | | CapAmbient | number | Ambient capability set, as a bitmask | | Securebits | number | The securebits flags in force for the process | ### Namespaces The isolated views of system resources a process sits in. Each value identifies one view: two processes sharing a value share that resource, and differing values are the clearest signal that a process is isolated from the host, whether by a container runtime, a sandbox, or a program that has isolated itself. These are operating system namespaces and have nothing to do with the Kubernetes namespace on [KubernetesPod](#kubernetespod). | Field | Type | Description | | --------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | UTS | number | Hostname and domain name | | IPC | number | Shared memory, semaphores and message queues | | Mnt | number | Which filesystems are mounted, and where | | PID | number | Which other processes are visible, and under what numbers | | PIDForChildren | number | The view processes started from here will be placed in. Differs from `PID` only between requesting a new view and starting the first child | | Net | number | Network interfaces, addresses, routes and ports | | Time | number | The system clock offsets a process observes | | TimeForChildren | number | Same requester-versus-children distinction as `PIDForChildren` | | Cgroup | number | How much of the resource-control hierarchy is visible from here | | User | number | How user and group identities map to those on the host, which is how an unprivileged user can appear as an administrator inside a container | ### Cgroup The resource-control group a process belongs to, as opposed to `Namespaces.Cgroup`, which describes only how much of the hierarchy the process can see. A process is always in one, container or not. | Field | Type | Description | | ----- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | | ID | number | Unique for as long as the group exists and not reused while it does, which makes it the key for attributing a process to a container | | Level | number | How far below the root of the hierarchy the group sits, where zero is the root itself. A container's group is never at the root | ### Process A running program, or one thread of it. PID alone does not identify a process over time because process numbers are recycled; PID together with `StartBoottime` does, within one boot of the host. Build identity on `StartBoottime` rather than `StartTime`, which is derived from the wall clock and inherits its error. | Field | Type | Description | | --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | PID | number | Process ID as seen from the host | | NSPID | number | Process ID as seen inside the process's own PID namespace | | TGID | number | Thread group ID, the ID of the program this thread belongs to | | NSTGID | number | Thread group ID as seen inside the process's own PID namespace | | StartBoottime | number | Nanoseconds since boot at which the process started | | StartTime | timestamp | Wall-clock time the process started | | PGID | number | Process group ID | | SID | number | Session ID | | LoginUID | number | The user who logged in, surviving later changes of identity. `4294967295` when unset | | SessionID | number | An identifier for that login. `4294967295` when unset | | RealCredentials | [Credentials](#credentials) | The identity the process was started with | | Credentials | [Credentials](#credentials) | The identity the process runs as now | | Namespaces | [Namespaces](#namespaces) | The kernel namespaces the process sits in | | Cgroup | [Cgroup](#cgroup) | The specific resource-control group the process belongs to, as opposed to `Namespaces.Cgroup`, which describes only how much of the hierarchy it can see. The primary key for attributing a process to a container | | Executable | [File](#file) | The running program. Only `Identity` is populated here | | NoNewPrivs | boolean | Whether the process is barred from gaining privileges through `exec` | ### Lineage A process together with the process that started it, captured at the same moment so the pair is internally consistent. | Field | Type | Description | | ------ | ------------------- | --------------------------- | | Task | [Process](#process) | The process itself | | Parent | [Process](#process) | The process that started it | ### Filesystem The filesystem a file resides on. Where a file lives is often as telling as what it contains: an executable on an in-memory or layered filesystem did not come from the host's own installation. | Field | Type | Description | | ----- | ------ | ------------------------------------------------------------------------------------- | | Type | text | Coarse filesystem type, e.g. `FILESYSTEM_TYPE_OVERLAYFS`, `FILESYSTEM_TYPE_TMPFS` | | Magic | number | The exact filesystem magic value the kernel reported, set even when `Type` is unknown | ### FileIdentity What distinguishes one file from another independently of the name it is reached by. Two observations agreeing on `Dev` and `Ino` describe the same file even under different paths; agreeing on `Size` and `Mtime` as well means it has not changed in between. | Field | Type | Description | | ----- | --------- | ----------------------- | | Dev | number | Device ID | | Ino | number | Inode number | | Size | number | File size in bytes | | Atime | timestamp | Last access time | | Mtime | timestamp | Last modification time | | Ctime | timestamp | Last status change time | ### Digest A measurement of a file's contents, which is what allows the same binary to be recognised wherever it appears and however it is named. | Field | Type | Description | | --------- | ------- | --------------------------------------------------------------------------------------- | | Algorithm | text | Digest algorithm, e.g. `HASH_ALGORITHM_SHA256` | | Value | text | Hex-encoded digest value | | Valid | boolean | False when no measurement was available, in which case the other fields are meaningless | ### File Everything known about a single file. Any observation populates the subset it actually has, so an absent field means "not observed", never "observed to be empty". | Field | Type | Description | | ------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | Path | text | File path | | Identity | [FileIdentity](#fileidentity) | Device, inode, size and timestamps | | Type | text | The kind of file, e.g. `FILE_TYPE_REGULAR` | | Filesystem | [Filesystem](#filesystem) | The filesystem the file resides on | | Digest | [Digest](#digest) | Measurement of the file's contents | | Deleted | boolean | Whether the file had already been removed when it was observed. Null when the file itself was not inspected | | PathTruncated | boolean | Whether `Path` ran out of room before it was complete. Null exactly where `Path` was not observed | ### FileDescriptor A numbered channel a program inherited when it started. Only 0, 1 and 2 are reported. A pipe or socket where a terminal is expected is a strong indication that a program's output is being captured. | Field | Type | Description | | ----- | ------------- | ----------------------------- | | FD | number | File descriptor number | | File | [File](#file) | What the descriptor refers to | ### Tty The terminal a process is attached to. | Field | Type | Description | | ------- | ------- | ------------------------------------------------------------------------------- | | Major | number | Major device number | | Minor | number | Minor device number | | Name | text | Terminal device name | | Present | boolean | False for a service, which has no terminal; the other fields then carry nothing | ### Image The image a container was created from. | Field | Type | Description | | --------- | ---- | ------------------------------------------------------------ | | Reference | text | The image reference, which is mutable and reusable | | Digest | text | The image digest, which is stable and always means one image | ### WorkloadOwner What ultimately created and owns a pod. A pod's own name is generated and replaced every time it is rescheduled, so this is the durable identity to attribute activity to. | Field | Type | Description | | ----- | ---- | -------------------------------------------------------------------------- | | Kind | text | Controller kind, e.g. `Deployment`. Clusters routinely add their own kinds | | Name | text | Controller name | ### KubernetesPod Where a container sits within an orchestrator, populated only when the host is managed by one. `Namespace` here is a Kubernetes namespace, a tenancy boundary within a cluster, which is a wholly different concept to the kernel [Namespaces](#namespaces) on a process. Nothing relates the two. | Field | Type | Description | | -------------- | ------------------------------- | ------------------------------------------------------- | | Name | text | Pod name, regenerated whenever the pod is rescheduled | | Namespace | text | Kubernetes namespace the pod runs in | | UID | text | Pod UID | | ContainerName | text | Name of the container within the pod | | Owner | [WorkloadOwner](#workloadowner) | The controller that owns the pod | | ServiceAccount | text | The identity the pod's processes present to the cluster | ### Container The container an event occurred inside. `CgroupPath` is observed directly and is present whenever the activity happened inside any resource-control group. Everything below it is resolved against the local container runtime and is absent when the runtime is unknown or unreachable, or when the activity did not happen inside a container at all. | Field | Type | Description | | ---------- | ------------------------------- | --------------------------------------------------------- | | CgroupPath | text | The cgroup path the activity happened in | | Runtime | text | The container runtime, e.g. `CONTAINER_RUNTIME_DOCKER` | | ID | text | Container ID | | Name | text | Container name | | Image | [Image](#image) | The image the container was created from | | Labels | map(text, text) | Labels attached at creation, carried whole | | Pod | [KubernetesPod](#kubernetespod) | Orchestrator attribution, when the host is managed by one | --- ## Multi-Party Approval # Multi-Party Approval (MPA) Multi-Party Approval (MPA) adds an additional layer of security by requiring multiple administrators to approve sensitive actions before they are executed. ## Overview When MPA is enabled, calls to protected API methods are not executed immediately. Instead, the request is queued and must be approved by the required number of workshop-admin users before it runs. MPA protection is applied per RPC method. There is no longer a fixed set of "MPA commands" — protection can be attached to almost any mutating method, and some methods are protected automatically. Each method falls into one of the following categories: - **Always protected** — protection is enforced in code and cannot be removed. - **Default protected** — protection is seeded as a recommended default but can be modified or removed by an administrator. - **Eligible** — any other mutating method can be opted into protection. - **Ineligible** — a small set of methods that cannot be protected. Protection can also be made **conditional** by attaching a [CEL expression](#conditional-protection-with-cel) to a method, so that MPA is only required when the expression matches the request. ## Protection Categories ### Always-protected methods These methods always require MPA when MPA is enabled. They cannot be removed or reconfigured through the API, which prevents an administrator from quietly stripping MPA protection off the controls that govern MPA itself (or off API key creation, which could otherwise be used to mint a privileged key and bypass approval). - `SetMultipartyApprovalSettings` - `DisableMultipartyApproval` - `AddMPAProtectedMethod` - `RemoveMPAProtectedMethod` - `ResetMPAProtectedMethods` - `CreateAPIKey` - `UpdateAPIKey` ### Default-protected methods When Workshop seeds the protected-methods list (on initial setup, or when the list is reset via `ResetMPAProtectedMethods`), it installs a recommended set of protections. Unlike always-protected methods, these **can** be modified or removed by an administrator after setup. | Method | Condition | | --------------------------- | -------------------------------------------- | | `KillProcessOnHostsWithTag` | Always (no condition) | | `CreateRule` | `request.rule.tag == "global"` | | `CreatePackageRule` | `request.rule.tag == "global"` | | `CreateFileAccessRule` | `request.rule.tag == "global"` | | `CreateRulesFromBundleHash` | `request.tag == "global"` | | `DeleteRule` | `resource.tag == "global"` | | `DeletePackageRule` | `resource.tag == "global"` | | `DeleteFileAccessRule` | `resource.tag == "global"` | | `BeginPasskeyRegistration` | Always (no condition) | | `DeletePasskey` | Always (no condition) | | `SetPasskeySettings` | Always (no condition) | | `UpdateSyncSettings` | Only when the save changes process overrides | | `DeleteSyncSettings` | `has(resource.process_overrides)` | The rule-related defaults are conditional: by default they only require approval when the action affects the `global` tag. The two sync-settings defaults only guard process overrides: a save that leaves them unchanged never requires approval. The list view marks a default method as "drifted" when its condition has been changed from the seeded default. ### Eligible methods Any method with a `write:` permission can be added to the protected list (with an optional condition), except for the ineligible methods below. Use `ListMPAProtectedMethods` to enumerate everything that is currently protected along with everything that is still eligible to be added. ### Ineligible methods A small number of methods cannot be protected, because doing so would deadlock the approval system or serve no purpose: - `ResolveMultipartyApprovalRequest` — approving a request cannot itself require approval, or no request could ever be resolved. - `ChatWithAI` — a conversational action, not an administrative mutation. - `PingAgent`, `CastVote`, `TestChatBot`, `TestBucket`, `ValidateCELRule` — operational/diagnostic actions. - `FinishPasskeyRegistration` — the second half of the WebAuthn registration ceremony, kicked off by the (protected) `BeginPasskeyRegistration` call. The browser's WebAuthn flow cannot tolerate the finish step failing with an MPA challenge, so protection is enforced on `BeginPasskeyRegistration` instead. ## Configuration MPA can be configured in the Workshop Settings page under the "Multi-Party Approval" section, or through the API. ### Required Approvers Specify how many approvers a request needs before it executes. This value must be at least **2**. The requestor is counted automatically as the first approver but **cannot** cast an approval on their own request. So a value of `2` means the requestor plus one other workshop-admin; a value of `3` means the requestor plus two others, and so on. MPA cannot be enabled unless the organization has at least this many workshop-admin users. ### Maximum Duration Set the maximum time an approval request can remain pending before it automatically expires. Expired requests are rejected automatically. The default is 24 hours. ### Exclude API Keys When enabled, requests made with an API key bypass MPA and execute immediately. This is useful for automation that should not block on human approval. Note that this only exempts callers authenticating with an API key — human-initiated calls to protected methods (including `CreateAPIKey` and `UpdateAPIKey`) are still gated. API keys can never approve or reject MPA requests. ## Conditional Protection with CEL A protected method can have an optional [CEL](https://cel.dev/) expression attached. When an expression is present, MPA is only required if the expression evaluates to `true` for that particular request; otherwise the call proceeds without approval. An empty expression means the method is unconditionally protected. Two variables are available to expressions: - `request` — the incoming RPC request message. For example, `request.rule.tag == "global"` matches a `CreateRule` call whose rule targets the `global` tag. - `resource` — the resolved target of the request, for methods whose request only carries an identifier. For example, `resource.tag == "global"` on `DeleteRule` loads the rule being deleted and inspects its tag. The `resource` variable is only populated for methods that have a resolver registered (currently `DeleteRule`, `DeletePackageRule`, and `DeleteFileAccessRule`); for other methods it is an empty value. Expressions must evaluate to a boolean and are validated when added — an invalid expression is rejected by `AddMPAProtectedMethod`. Evaluation **fails closed**: if the resource cannot be resolved or the expression errors at runtime, the request is treated as requiring MPA rather than allowed through. ## How It Works 1. An administrator calls a protected method. 2. If MPA is disabled, or the caller is an API key user and "Exclude API Keys" is enabled, the call proceeds normally. 3. If the method has a CEL condition that evaluates to `false` for this request, the call proceeds normally. 4. Otherwise, instead of executing, an approval request is created and the call returns a `FailedPrecondition` error carrying the transaction ID (`x-mpa-txid`). 5. Other workshop-admin users view pending requests and approve or reject them. 6. Once the required number of approvers is reached, the original caller re-issues the identical request. The interceptor finds the approved, unconsumed approval, atomically claims it, and executes the method exactly once. The result is recorded against the approval. 7. If the request expires before reaching enough approvals, it is automatically rejected. Because an approval is matched against the exact serialized request, the approved call must be re-issued with the same arguments to execute. ## Security Considerations - Administrators cannot approve their own requests. - Each administrator can only approve a request once. - The requestor is counted as the first approver but does not cast an approval, so a genuine second admin is always required. - API keys can neither approve nor reject requests, so MPA cannot be bypassed by provisioning additional keys. - Approvals are consumed atomically before the handler runs, so a single approval cannot be replayed to execute the action more than once. - The requestor may cancel (reject) their own request even after it has reached the approval threshold, as long as it has not yet executed. - Protection for the methods that govern MPA itself — and for API key creation — is always enforced and cannot be removed. - All MPA activity (requested, approved, rejected, expired) is written to the audit log, linked by the request's transaction ID. ## API Methods ### Settings and requests - `GetMultipartyApprovalSettings` — Retrieve current MPA configuration (and the current workshop-admin count). - `SetMultipartyApprovalSettings` — Update MPA configuration. - `DisableMultipartyApproval` — Disable MPA (preserving other settings). - `ListMultipartyApprovalRequestsForSession` — List approval requests, including which ones the calling user can act on. - `GetMultipartyApprovalRequest` — Get details of a specific request by txid. - `ResolveMultipartyApprovalRequest` — Approve or reject a request. ### Managing protected methods - `ListMPAProtectedMethods` — List currently protected methods (with their conditions, and whether they are always- or default-protected) along with all eligible methods that could be added. - `AddMPAProtectedMethod` — Protect an eligible method, optionally with a CEL expression. - `RemoveMPAProtectedMethod` — Stop protecting a method. Always-protected methods cannot be removed. - `ResetMPAProtectedMethods` — Remove all user-configured protections and restore the default set. Always-protected methods are unaffected. --- ## Reports # Reports The Reports interface provides comprehensive insights into your Santa deployment, helping administrators understand security posture, rule effectiveness, and system health across the organization. ## Overview The Reports dashboard offers several analytical views: - **Top Blockables**: Most frequently blocked binaries across your organization - **Dangerous Entitlements**: Binaries with potentially dangerous entitlements - **Ready for Lockdown**: Analysis of hosts that may be ready to transition to Lockdown mode ## Top Blockables The Top Blockables report displays the most frequently blocked binaries across your organization: - **Filename**: Name of the blocked binary - **Signing ID**: The signing identifier of the binary - **CDHash**: The CodeDirectory hash of the binary - **Entitlements**: Any entitlements associated with the binary - **Count**: Number of times the binary has been blocked This report helps identify patterns of blocked applications and potential security risks. ## Dangerous Entitlements The Dangerous Entitlements report highlights binaries that contain potentially risky entitlements: - Displays binaries with entitlements that could pose security risks - Provides detailed information about each entitlement - Helps identify applications that may require additional scrutiny ## Ready for Lockdown TODO --- ## Risk Engine # Risk Engine The Risk Engine empowers security teams to create policies that automatically identify when applications exceed your organization's risk tolerance. You can configure these policies to flag applications for various reasons, such as known malware detection or organization restrictions on virtualization software. The system uses a flexible plugin architechture, allowing multiple plugins to participate in the process of deciding whether a given application is above or below the line of risk. When the Risk Engine is enabled, every time Santa uploads an event to Workshop the binary / application inside the events are passed to it. The Risk Engine then generates an authorization request for each of its plugins in parallel, setting a deadline. If all plugins return an `ALLOW` decision within the deadline then the event is considered safe. If any plugins returns `DENY` or `DENY_MALWARE` then the event is considered dangerous. If any plugins return errors or respond after the deadline they are treated as if the plugin returned a `DENY` decision. ## Unknown Binaries The binaries the Risk Engine evaluates are the ones Santa considers **unknown**: no rule covers them, so Santa found nothing matching the binary's hash, signing identity, or any other rule scope and has no verdict of its own for it. What Santa does with an unknown binary depends on the host's client mode. In Monitor mode it allows the execution and reports the event with an `ALLOW_UNKNOWN` decision; in Lockdown mode it blocks the execution and reports `BLOCK_UNKNOWN`. In both cases the event reaches Workshop, and by default the Risk Engine evaluates the binary either way. ## Configuration ### UI The Risk Engine can be configured in the Settings page. ### API Methods The Risk Engine can be configured using the [UpdateRiskEngineSettings](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.UpdateRiskEngineSettings) method. ### Only Evaluate Blocked Executions As described in [Unknown Binaries](#unknown-binaries), the Risk Engine evaluates unknown binaries whether Santa allowed the execution (`ALLOW_UNKNOWN`) or blocked it (`BLOCK_UNKNOWN`). That makes it two things at once: the gate that decides approvals, and a detection tool that scores software which has already run. Enabling **Only evaluate blocked executions** (`only_evaluate_blocked_events`) narrows it to the approval gate. Turn it on for a fleet in Lockdown where the Risk Engine's job is to decide approvals and you do not want it used for detection. It restricts event-driven evaluation to binaries Santa blocked in Lockdown (default deny) mode because they lacked rule coverage. It applies to the whole Risk Engine, not to an individual plugin. When only evaluating blocked executions, the Risk Engine is only invoked when Santa has blocked a binary in preparation for an approval workflow, and its result is cached. A binary that was only ever allowed is never scored speculatively — it only enters the shared result cache if someone asks to approve it. Two behaviors change when this is enabled: - **Approvals still work.** When someone requests approval for a binary that was skipped, Workshop evaluates it on demand and stores the verdict. The first approval for that binary pays the plugin round-trip. - **The 6-hour background refresh is disabled.** That job has no execution context, so it cannot tell which cached results belong to blocked executions. Cached verdicts are refreshed lazily at approval time instead. The trade-off is the detection coverage you give up. Unknown binaries that Santa allowed rather than blocked — hosts still in Monitor mode, or hosts using [On-Demand Monitor Mode](./settings#on-demand-monitor-mode) — have no Risk Engine verdict, so they appear empty in: - the Risk Engine column on the Apps page - Recent Apps - per-host Risk Engine results - the Santa vs. Risk Engine conflict chart Leave this off if you rely on the Risk Engine to flag software that has already run. ## Internal vs. Remote Plugins Risk Engine plugins come in two flavors - Internal, which are included as part of Workshop and Remote, which are extensions that can be written by customers or North Pole Security. ## Secrets URL Some options in Risk Engine plugins can be configured to use AWS and GCP secret stores. ### AWS 1. Give the Workshop service account read access to the secret The Workshop service account is displayed at the top of the Settings page 2. Pass the ARN to the secret prefixed with `aws://` e.g. `aws://arn:aws:secretsmanager:us-east-1:940000000003:secret:Secret-YYLN9X` ### GCP 1. Give the Workshop service account read access to the secret The Workshop service account is displayed at the top of the Settings page 2. Specify the path to the secret as `gcp://projects//secrets//versions/latest` ## Included Plugins Workshop's internal plugins include: ### VirusTotal The VirusTotal plugin will check the SHA-256 of the binary against VirusTotal using the file report API. The VirusTotal plugin will cache results per user defined parameters. This ensures that results are timely and saves expensive API calls. #### Options - _API Key_ - Your VirusTotal Key; this is either the raw string or a [secrets URL](#secrets-url) - _Cache Time_ - How long in seconds the cache entries should be kept alive for in seconds - _Cache Entries_ - How many entries to cache - _Excluded Engines_ - A list of engines to exclude results from ### Reversing Labs The Reversing Labs plugin will check the SHA-256 hash of a binary against ReversingLabs Spectra file reputation API. If the API deems it malicious it returns a `DENY_MALWARE` response. The ReversingLabs plugin will cache results per user defined parameters. This ensures that results are timely and saves expensive API calls. #### Options - _Username_ - your reversing labs username - _Password_ - your reversing labs username or a secret URL - _Cache Entries_ - How many entries to cache - _Cache Time_ - How long in seconds the cache entries should be kept alive for - _Cache Entries_ - How many entries should be cached (up to 50,000) ### Blockable Rules The Blockable Rules plugin allows you to write rules using the [Common Expression Language](https://cel.dev/) to match properties of a _blockable_. A blockable is a collection of attributes from the binary that Santa or Workshop policy can be matched on. All matchable attributes will be populated into the `blockable` object. If the CEL expression returns true then this plugin will return a `DENY` decision, otherwise it will return an `ALLOW`. You may also use [CEL macros](https://github.com/google/cel-go?tab=readme-ov-file#macros) for working with nested structures such as entitlements. This is an extremely powerful feature that allows you to flag entire classes of software. Furthermore it allows you to tailor an extremely granular policy. For example to have the risk engine return a `DENY` decision for any virtualization software you can use the following rule: ```clike has(blockable.entitlements) && blockable.entitlements.exists(e, e.key == "com.apple.security.hypervisor") || blockable.entitlements.exists(e, e.key == "com.apple.security.virtualization") ``` You can use all of the attributes of a [blockable](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.BinaryBlockable) in rules. This includes: - `sha256` - The SHA-256 of the binary - `cdhash` - The CDHash of the binary - `signing_id` - The signing ID, prefixed with either the team ID or platform - `team_id` - The 10-digit alphanumeric Team ID of the binary that uniquely identifies the publisher - `signed_by` - The certificate chain - `entitlements` - The array of entitlements provided ## Remote Risk Engine Plugins In addition to the included plugins the Risk Engine can be extended via ### Writing Your Own Remote Risk Engine Plugins To write your own remote risk engine plugin you need to simply create a server that takes an HTTP POST with JSON consisting of the `PluginAuthzRequest` and that returns an `RemoteRiskEnginePluginServiceAuthorizeRequest` serialized to JSON. The interaction is essentially as follows: ```mermaid sequenceDiagram Workshop ->> Plugin: Makes an RemoteRiskEnginePluginServiceAuthorizeRequest for a binary Plugin -->> Workshop: Returns a RemoteRiskEnginePluginServiceAuthorizeResponse containing a policy decision ``` :::note Plugin authors are responsible for TLS and authorization. ::: #### Handling Requests The first step is to make a web service that can receive and unmarshal a `PluginAuthzRequest`. ```proto // A RemoteRiskEnginePluginServiceAuthorizeRequest is a request made by Workshop to // a plugin to authorize a binary / blockable. message RemoteRiskEnginePluginServiceAuthorizeRequest { string tx_id = 1; // The transaction ID of the request. BinaryBlockable blockable = 2; // The binary to authorize with all blockable attributes. google.protobuf.Timestamp timestamp = 3; // The timestamp of the request. google.protobuf.Timestamp deadline = 4; // The deadline for the plugin to return a decision before it is automatically considered a denial. } ``` After unmarshaling the `RemoteRiskEnginePluginServiceAuthorizeRequest` you can find all of the details about the binary in the `blockable` field. This contains a subset of the attributes Santa has recorded at the time of execution, including signing information. Each request has a transaction ID (`tx_id`) field and all responses are expected to have the same value in their transaction ID field. Each `RemoteRiskEnginePluginServiceAuthorizeRequest` also contains a `deadline` that the plugin must respond with a `PluginAuthzResponse` before to be considered. Failure to respond within the deadline will be treated as a if the plugin had responded with a deny decision. Once the data from the request has been processed a `RemoteRiskEnginePluginServiceAuthorizeResponse` must be send back to Workshop with a decision and and explanation for the decision. The structure of the `RemoteRiskEnginePluginServiceAuthorizeResponse` is as follows: ```proto // This message is used by a remote risk engine plugin to represent the decision // for a blockable. All errors and timeouts are treated as denials. message RemoteRiskEnginePluginServiceAuthorizeResponse { string tx_id = 1; // The transaction ID of the request this response is for. Decision decision = 2; // The decision for the blockable. Explanation explanation = 3; // An explanation for the decision. string error = 4; // An error message containing any errors the plugin encountered. string plugin_uuid = 5; // The UUID of the plugin that made the decision. google.protobuf.Timestamp good_until = 6; // The time the decision is considered valid until for caching. } ``` Decisions can be one of the following: | Decision | Meaning | | ------------ | --------------------------------------------------------------------- | | UNKNOWN | This is a programming error and should not be used | | DENY | The plugin has determined the binary should be blocked by policy. | | DENY_MALWARE | The plugin has determined the binary is malware and should be blocked | | ALLOW | The plugin believes this binary is safe. | | TIMEOUT | The plugin or something it depends on has timed out | | ERROR | The plugin has encountered an error | All decisions except for allow are considered a denial. See [the Decision proto for more details](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.Decision) Additionally plugin authors are expected to provide an explanation for the decision and optionally a URL for getting more information. Workshop presents this information to to users and also helps with debugging. See [the Explanation proto for more details](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.Explanation) ## Exceptions Risk Engine plugin decisions can be overridden using Exceptions. These are created through the UI or API and grant users in a targeted tag an exception to specific Risk Engine plugin decisions. All exceptions include an expiration date after which they no longer apply. For example, if the Risk Engine's Blockable Rules plugin has a rule banning VPN software e.g. ```clike has(blockable.entitlements) && blockable.entitlements.exists(e, e.key == "com.apple.developer.networking.networkextension" && e.value.contains("packet-tunnel-provider-systemextension")) ``` But you wanted to let members of the tag `vpn-access` approve their own VPN software then you could accomplish this by granting the exception to tag for the specific Blockable Rule. ### Expiry Exceptions all have an expiration date built into them after which point they will not longer be considered. This can be updated via both the UI and API. ### Configuration #### UI Exceptions can be configured via the UI under the Exceptions tab on the Risk Engine Settings portion of the main Settings page. #### API Exceptions can also be mangaged via the [CreateException](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.CreateException),[UpdateException](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.UpdateException), [ListExceptions](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.ListExceptions), and [DeleteException](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.DeleteException) methods in API --- ## Rules # Rules Workshop provides comprehensive rule management for controlling system behavior across your organization. Rules define policies for execution control, file access authorization, and network flow authorization. ## Rule Categories ### Execution Rules Control which binaries can execute on your systems. Execution rules use Santa's binary authorization capabilities to allow or block applications based on cryptographic signatures, certificates, and other identifiers. [Learn more about Execution Rules →](/rules/execution-rules) Execution rules can also carry a CEL program that decides allow/block dynamically based on execution context. [Read the Complete Guide to CEL in Santa Rules →](/rules/cel-guide) ### Sandbox Rules Require a binary to be launched under `santactl sandbox` with a macOS Seatbelt (SBPL) profile attached, so it runs confined instead of being simply allowed or blocked. Requires Santa 2026.6+. [Learn more about Sandbox Rules →](/rules/sandbox-rules) ### Time Based Rules Gate an execution rule on a time window, and optionally quit what it allowed when the window closes. Time based rules build on CEL. The window is written into the rule's CEL expression with `policy_for_range()`. Requires Santa 2026.8+. [Learn more about Time Based Rules →](/rules/time-based-rules) ### File Access Rules Regulate which processes can read and write files on macOS systems. File Access rules provide fine-grained control over file system access, enabling monitoring, logging, and blocking of access attempts. [Learn more about File Access Rules →](/rules/file-access-rules) ### Network Rules Authorize, deny, or audit network connections on macOS hosts. Network rules match connections by local process, remote peer, and transport (ports, protocols, direction), using Santa's network extension. Requires the network extension tenant feature. [Learn more about Network Rules →](/rules/network-rules) ### Package Rules Target software by name in a package catalog and let Workshop materialize execution rules for it, kept in sync as new versions ship. Optional CEL filters narrow a rule down to specific versions and binaries. [Learn more about Package Rules →](/rules/package-rules) ### Rule Packs Subscribe a tag to a curated set of rules maintained by North Pole Security. Each pack's rules are materialized into ordinary, editable Workshop rules and kept in sync as the pack is updated. [Learn more about Rule Packs →](/rules/rule-packs) --- ## Settings # Settings The Settings interface provides a centralized control panel for configuring Workshop's global settings that affect all hosts in your organization. This dashboard allows administrators to manage default sync settings, removable media blocking behavior, and user access. ## Default Client Mode The Client Mode setting determines the default enforcement behavior for all Santa agents across your organization: - **Monitor**: Allows all executions but logs them for review - **Lockdown**: Only allows executions that match allowlist rules - **Standalone**: Operates without connecting to the sync server Individual hosts can be assigned different modes that override this global default. [Learn more about modes](https://northpole.dev/concepts/mode.html) ## On-Demand Monitor Mode {#on-demand-monitor-mode} On-Demand Monitor Mode allows hosts to temporarily transition into Monitor Mode for a limited duration. This feature is useful when users need to execute applications that would normally be blocked in Lockdown mode, without permanently changing the host's enforcement mode. When enabled, hosts can request temporary Monitor Mode access through the Santa client. The duration of this temporary access is controlled by two settings: - **Max Minutes**: The maximum number of minutes a machine is allowed to transition into Monitor Mode. Valid range: 1-43,200 minutes (1 minute to 30 days). This setting acts as an upper bound for any Monitor Mode request. - **Default Duration Minutes**: The default number of minutes of Monitor Mode granted when requested if no duration is explicitly specified. If set to 0 or not specified, the Max Minutes value is used as the default. This value must not exceed Max Minutes. When On-Demand Monitor Mode is disabled, hosts cannot request temporary Monitor Mode access and must rely on their configured Client Mode setting. ## Temporary Admin Mode {#temporary-admin-mode} Temporary Admin Mode lets standard users elevate to a local administrator for a limited, automatically revoked window. This feature is useful when users occasionally need administrator rights but should not hold them permanently, since standing privilege is a target for malware and attackers. Temporary Admin Mode is Santa's Endpoint Privilege Management (EPM) capability and is currently in **Beta**. When enabled, Santa demotes existing local administrators (accounts with a UID of 500 or higher) to standard users. Those users can then request temporary elevation from the Santa menu bar or with the `santactl adminmode` command. Local authentication is always required before a user can elevate, and elevation is revoked automatically when the granted window expires or the screen locks. Every elevation is audited: Santa records who elevated, when, and any justification they provided, then uploads those audit events to Workshop. Temporary Admin Mode is configured per-tag under the **Admin Mode** tab in Sync Settings. These settings override global settings and any tag settings with lower precedence. The duration of each elevation is controlled by two settings: - **Max Minutes**: The maximum number of minutes a user may be elevated to administrator. Valid range: 1-43,200 minutes (1 minute to 30 days). This setting acts as an upper bound for any elevation request. - **Default Duration Minutes**: The default number of minutes granted when no duration is explicitly specified. If set to 0 or not specified, the Max Minutes value is used as the default. This value must not exceed Max Minutes. You can also require users to supply a justification each time they elevate. Because Temporary Admin Mode changes who holds local administrator rights, review the following before enabling it: - Santa manages direct members of the local admin group only. It does not resolve nested group memberships, such as a directory group nested inside the admin group. - Platform SSO and network (directory-based) accounts should work, but verify the behavior in your environment before relying on it. - Santa does not manage macOS Secure Tokens or Bootstrap Tokens. It assumes an MDM or an existing local administrator already holds one, so token-gated operations like FileVault keep working. When Temporary Admin Mode is disabled, Santa does not manage local administrator membership and users keep their existing access. If you disable it after it has managed a host, Santa restores the prior administrators on a best-effort basis. ## Removable Media Blocking Removable media blocking controls whether Santa will block the mounting of removable storage devices: - **Disabled**: removable media devices can be mounted normally - **Enabled**: removable media devices will be blocked from mounting - **Enabled with Remount Flags**: removable media devices will be blocked, but can be remounted with specific flags ## Sync Intervals Sync Intervals control how frequently Santa agents communicate with the Workshop server to retrieve updated rules and configurations. - **Full Sync Interval**: Determines how often hosts perform a progressive sync with the server. Valid range: 60-86,400 seconds (1 minute to 24 hours). Default is 600 (10 min). - **Push Notification Full Sync Interval**: When Push Notifications are enabled, this setting determines how often hosts perform a progressive sync with the server. Valid range: 60-86,400 seconds (1 minute to 24 hours). Default is 14400 (4 hours). ## CEL Fallback Rules CEL Fallback Rules allow you to define expression-based policies that are evaluated when no other rule matches a binary. They can be used to implement broad organizational policies — such as blocking binaries with specific entitlements or requiring Touch ID for unsigned software — while still allowing standard rules to take priority for specific applications. CEL Fallback Rules are configured per-tag in Sync Settings and require Santa 2026.3 or later. For full details on CEL expression syntax, available input variables, and return values, see [CEL Policy Rules](/rules/execution-rules#cel-fallback-rules). ## Process Overrides {#process-overrides} Some processes touch every path you might protect. Spotlight and XProtect Remediator read nearly every file on disk, so every path-centric File Access Rule would have to list them. Process overrides let you list them once instead. Each entry names a process (by signing ID, team ID, path, CDHash, or certificate hash) and an action: Allow, Audit, or Deny. When a host syncs, Workshop adds the whole list to every path-centric File Access Rule it downloads. A Deny entry does the opposite job: it blocks one process across every protected path. Process overrides are configured per-tag in Sync Settings and require Santa 2026.8 or later. As with other sync settings, the highest-precedence tag that sets the list supplies all of it. Lists from different tags are never merged. An empty list is a valid setting: it removes every inherited entry, including the Spotlight and XProtect entries Workshop seeds on the Global tag. Overrides never reach process-centric rules. If a rule lists the same process itself, the rule's own entry wins. A rule can also opt out of overrides entirely with its **Apply process overrides** checkbox. See [File Access Rules](/rules/file-access-rules#process-overrides-from-tag-settings) for how entries are applied and what older agents receive. ## Telemetry Filter Expressions Telemetry Filter Expressions are CEL expressions evaluated by Santa on the client to drop or redact events before they are uploaded to your telemetry bucket. They are configured per-tag in Sync Settings under the **Telemetry** tab. For syntax, available variables, and worked examples, see [Telemetry Filter Expressions](/telemetry/filter-expressions). ## Santa Auth Workshop supports two methods for authenticating Santa clients. Changes to authentication methods will affect the generated config shown on the Santa tab. If both Token and mTLS authentication are enabled, the config will use the mTLS configuration. ### Token Authentication Token-based authentication allows Santa clients to authenticate using bearer tokens. You can manage authentication tokens from the Settings interface: - **Multiple Tokens**: Create and manage multiple authentication tokens for different deployments or environments - **Last Used Tracking**: Each token displays the timestamp of its last use, helping you identify active and inactive tokens - **Token Deletion**: Tokens can be deleted when they are no longer needed, immediately revoking access for any clients using that token When using token authentication, Santa clients connect to the standard `SyncBaseURL` (e.g., `https://tenant.workshop.cloud/santa`). ### mTLS Authentication Mutual TLS (mTLS) authentication provides certificate-based authentication for enhanced security. Workshop supports configuring multiple Certificate Authority (CA) certificates from the Settings interface, allowing you to manage certificates for different organizational units or for seamless transition between issuing CAs. When mTLS is enabled, the `SyncBaseURL` key in Santa's configuration will include an `mtls.` prefix (e.g., `https://mtls.tenant.workshop.cloud/santa`). This special URL only works when mTLS authentication is properly configured on both the Workshop server and the Santa client. **Important**: The mTLS-prefixed URL will only accept connections from clients presenting valid certificates signed by one of the configured CA certificates. Standard token-based authentication will not work with the mTLS URL. ## Santa Releases The **Releases** tab on the Santa settings page lists the latest Santa release available to your tenant, with one row per installer. Only macOS installers are listed for now, so you will see a row for each `.pkg` the build publishes. Each row shows the platform, the build version, when it was released, the file name and size, and its SHA-256 digest. Use **Download** to fetch the installer, then deploy it with your MDM alongside the configuration profile from the Configuration tab. Where a build has release notes, its rows carry a **Notes** button that shows them in a window. Download links are short-lived. If one has expired, reload the page to get a fresh link. ## User Management Workshop has two distinct user systems that serve different purposes: 1. **SSO Users**: Users who can log into the Workshop web interface 2. **Directory Users & Groups**: Users and groups that represent your organization's identity structure for policy assignment ### SSO Users SSO Users are those who can access the Workshop web interface. These users authenticate via Single Sign-On (SSO) through your identity provider. From the Settings page, you can: - **Configure SSO**: Set up your identity provider connection - **Verify Domains**: Confirm ownership of your organization's email domains - **Manage Users**: View and manage users who can log into Workshop - **Assign Roles**: Control permissions by assigning roles to SSO users ### Directory Users & Groups Directory Users and Groups represent your organization's identity structure. When a host reports its [primary user](https://northpole.dev/configuration/keys#MachineOwner), Workshop looks up that user in the directory to determine which groups they belong to. Groups can have [tags](./tags) assigned to them, which are then automatically applied to the host. This enables powerful policy automation: instead of manually tagging each host, you can assign tags to groups in your directory, and hosts will automatically inherit the correct tags based on their primary user. ### Directory Type Workshop supports two modes for managing Directory Users and Groups: #### Directory Sync (DSYNC) In DSYNC mode, users and groups are automatically synchronized from an external directory service via SCIM. **Advantages:** - Users and groups stay in sync with your identity provider(IdP)'s external directory automatically - No manual maintenance required - Changes in your identity provider(IdP)'s external directory are reflected in Workshop - Leverage existing group structures for policy assignment **Configuration:** 1. Set the Directory Type to "Directory Sync" 2. Click "Configure Directory Sync" to set up the SCIM connection 3. Use "Trigger Directory Sync" to force an immediate sync #### Local Directory In Local mode, users and groups are created and managed manually within Workshop. **Advantages:** - No external directory service required - Full control over user and group definitions - Useful for testing or organizations without centralized identity management - Can define groups that don't exist in your identity provider(IdP)'s external directory **Configuration:** 1. Set the Directory Type to "Local" 2. Create users and groups directly in the Users and Groups tabs or via [the API](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.CreateUser) 3. Manually assign users to groups as needed or via [the API](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.AddUserToGroup) :::warning Changing the directory type will delete all existing users and groups of the current type. This action cannot be undone. ::: ### Assigning Groups to Hosts There are two ways hosts can be associated with groups: #### Via Primary User When using Directory Sync, Workshop automatically looks up the host's primary user in the directory and applies tags from any groups that user belongs to. This happens during each Santa sync. For example, if user `alice@example.com` is the primary user of a MacBook and she's a member of the "Engineering" group in your IdP's external directory, the MacBook will automatically receive any tags assigned to the "Engineering" group in Workshop. #### Via Primary User Groups (Client-Defined) Starting with Santa **version 2025.6**, you can define [primary user groups](https://northpole.dev/configuration/keys#MachineOwnerGroups) directly in the Santa configuration. Workshop will look up these group names in the directory and apply their tags, even if the primary user isn't a member of those groups in your IdP's external directory. See [Tags](./tags) for more details on how group membership affects tag assignment. ## Slack See [Slack Settings](./slack) for more information. ## Workshop Updates Workshop provides flexible update management to keep your server current with the latest features, improvements, and security patches. You can choose between automatic updates with configurable policies or manual updates triggered on-demand. ### Update Process Workshop's update mechanism is designed to be seamless and zero-downtime. When an update is triggered (either automatically or manually), the current version continues serving traffic during the update. Once the update completes, the new version automatically takes over. This ensures continuous availability throughout the update process. All update activities are recorded in the audit log for compliance and troubleshooting purposes, whether triggered automatically by the system or manually by a user. :::note While the update process is seamless there can be a short period during the update where the web UI may attempt to partially load both old and new versions, causing errors. If this happens, wait a few minutes and refresh the page. This has no impact on Santa client syncing or API use. ::: ### Manual Updates You can manually apply updates at any time, regardless of automatic update settings: 1. Navigate to **Settings** → **Administration** in the Workshop interface 2. In the **Updates** section, view the current version and available updates 3. If updates are available, select the desired version from the dropdown 4. Click **Apply Update** to install the selected version immediately Manual updates are not restricted by automatic update modes or time windows, giving you full control to update on your schedule. The triggering user is recorded as the actor in audit logs for manual updates. ### Automatic Updates Workshop can automatically install updates based on policies you define, eliminating the need for manual intervention while maintaining control over when and what gets updated. #### Automatic Update Modes Workshop provides three automatic update modes to match your organization's policies: - **Disabled**: Automatic updates are turned off. All updates must be triggered manually through the Workshop interface or API. - **All Updates**: Automatically installs all available Workshop updates as soon as they're released. Use this mode to stay current with the latest features and improvements. - **Security Updates Only** (Default): Automatically installs only updates that contain security fixes. Feature releases and other non-security updates are skipped. This mode is recommended for production environments that prioritize stability while ensuring critical security patches are applied promptly. #### Update Scheduling When automatic updates are enabled, Workshop checks for updates every hour and installs them immediately if an update matching your configured mode is available. You can optionally restrict when automatic updates are installed by configuring a time window. **Update Window Configuration:** - **Any Time** (Default): Updates can install during any hour. No restrictions are applied. - **Specific Hours**: Define a start hour and end hour (in your local timezone) to restrict automatic updates to a particular window. For example, configure updates to only install between 2:00 AM and 6:00 AM to minimize disruption during business hours. - **Overnight Window**: If the start hour is later than the end hour (e.g., 10:00 PM to 6:00 AM), the window wraps around midnight. This is useful for scheduling updates during off-hours. :::note Time windows are stored in UTC internally but displayed in your browser's local timezone for convenience. The system checks for available updates every hour, and if an update matching your mode is available and the current hour falls within your configured window, the update will be installed automatically. ::: #### Configuring Automatic Updates To configure automatic update settings: 1. Navigate to **Settings** → **Administration** in the Workshop interface 2. Scroll to the **Automatic Updates** section 3. Select your preferred automatic update mode 4. Optionally configure a time window to restrict when automatic updates can install 5. Click **Save Settings** to apply your changes Changes to automatic update settings are recorded in the audit log. --- ## Slack # Slack Bot Included with Workshop is a Slack chat bot that can help users go through an approvals workflow in Slack. ## Configuring Slack In order to use the Slack Bot integration you must have permissions to generate a [Configuration token](https://api.slack.com/concepts/token-types#config) in your Slack workspace. - Follow the instructions at https://api.slack.com/reference/manifests#config-tokens to create a configuration token. For an Slack admin user this is usually at the bottom of [https://api.slack.com/apps/](https://api.slack.com/apps/) - In Workshop, go to the Settings page and scroll down to the Slack settings card, then click the "Initialize Slack Bot" button - In the modal paste the configuration token from step one and click Submit - Click Close - Open [https://api.slack.com/apps](https://api.slack.com/apps) and select the Workshop App. - Customize it to your liking, including icon or change the name of the bot. - Install the app into your Slack workspace by selecting Settings > Install App and clicking the button - Collect the Slack Bot token and Signing Secret. ## Configuring Workshop Next you need to configure Workshop's bot to use the new Slack app. In Workshop start by going to the Settings page and scrolling down until you see the Slack Settings card. ### Fill in your Slack Workspace Name This is the portion of your Slack workspace domain name before the `slack.com`, portion. For example if your workspace is `example.slack.com` then your workspace for workshop should be listed as `example` ### Fill in your Slack Bot token in the Slack Token field You can specify the slack bot token from step 7 of the previous section to store the token in the database. This field also supports using the AWS and GCP secret stores. If you are using AWS, you can use SecretManager by doing the following: - Give the Workshop service account read access to the secret - The Workshop service account is displayed at the top of the Settings page - Pass the ARN to the secret prefixed with a `aws://` e.g. `aws://arn:aws:secretsmanager:us-east-1:940000000003:secret:SlackSecret-YYLN9X` If you are using GCP, you can use SecretsManager by doing the following: - Give the Workshop service account read access to the secret - The Workshop service account is displayed at the top of the Settings page - Specify the path to the secret as `gcp://projects/projectID/secrets/secretID/versions/latest` ### Fill in your HMAC secret The HMAC secrete ensures that Workshop will only receive traffic from Slack The HMAC secret is the signing secret from the previous Slack section. Simply cut and paste this here. Additionally this can also use the secret stores just like the slack token in the previous section ### Save your slackbot settings Simply save your settings using the save settings button. ## Required Scopes The Workshop Slack app requires the following OAuth scopes. These are automatically configured if you use the manifest-based installation flow. ### Bot Token Scopes | Scope | Purpose | | ------------------ | ---------------------------------------------------------------- | | `channels:join` | Join designated approval channels to post notifications | | `channels:read` | List available channels for configuration | | `channels:history` | Update approval messages in public channels | | `chat:write` | Send approval and notification messages | | `groups:read` | List private channels the bot has been added to | | `groups:history` | Update approval messages in private channels | | `groups:write` | Create and post in group conversations for approver workflows | | `im:read` | List direct message conversations | | `im:write` | Send direct messages to users for approval notifications | | `im:history` | Update approval messages in direct messages | | `mpim:read` | List group chats created for social voting workflows | | `mpim:history` | Update approval messages in social voting group chats | | `mpim:write` | Create group chats and post messages for social voting workflows | | `users:read` | Look up user information for approval workflows | | `users:read.email` | Map users by email address for designated approver workflows | ### User Token Scopes | Scope | Purpose | | ---------------- | ------------------------------------------ | | `identity.email` | Read the user's email for identity mapping | | `openid` | OpenID Connect authentication | ## Configure Your Approvals Workflow to use Slack Notifications The Slack bot will only send messages when all of the following are true: - Santa has blocked an application from running on a users system - The user is running in [Lockdown mode](https://northpole.dev/features/binary-authorization/#client-mode) and does not have an explicit rule allowing it - The user is part of a tag that has approval workflows configured to use Slack notifications ## Configuring via the API All of the above steps aside from the Slack portions can be accomplished using the `InstallChatBot` and `UpdateChatSettings` API methods. Additionally these settings can be saved using the `GetChatSettings` API. All methods require the `settings:write` and `settings:read` permissions. ## Configuring Santa (optional) By default Santa will redirect users to the web based approvals workflows. If you want users to go directly to the Slack message when an application is blocked you can specify an [EventDetail](https://northpole.dev/configuration/keys/#EventDetailURL) of `https://.workshop.cloud/slack/details/%machine_id%/%file_identifier%` to have the open button in the Santa modal direct users to Slack. --- ## Tags # Tags Tags are a flexible mechanism for assigning rules and settings to hosts. ## Concepts - **Tag** - a named collection of settings and rules applied to a set of hosts. By default there's always a **global** tag which applies to all hosts connected to Workshop and each host gets a tag defined as **host:\**. - **Tag Order** - the order in which tags inherited by groups are applied to hosts. This defines the precedence used when resolving settings and rules. - **Overridden Tags** (deprecated) - when an admin explicitly sets the tags on a host via the `UpdateHost` API. This locks the tag set on the host so it is no longer derived from group membership. Hosts with overridden tags cannot participate in approval workflows. Host-level tag overrides are being removed: the UI no longer offers them, the API field is deprecated, and hosts that need specific policy should get it from a dedicated tag instead. A locked host can be returned to group-derived tags from its overview page. - **Sync Settings** - options that change how Santa operates on a given host. When applied to tags they are resolved from least specific to most specific tag. Each individual setting is replaced atomically by a higher-precedence tag — values are never merged within a single setting. For example, a device cannot combine Allowed Path Regex values from multiple tags; only the highest-precedence tag's value applies. This means different settings can come from different tags (e.g. one tag sets Client Mode, another sets Blocked Path Regex), but any single setting is wholly owned by the most specific tag that defines it. Settings that follow this atomic override behavior include: - Client Mode - Removable Media Blocking - Network Share Blocking - Transitive Rules - Allowed Path Regex - Blocked Path Regex - Telemetry Collection - Network Extension - **Approval Workflow Settings** - these define if the hosts running Santa that are part of the tag can use approval workflows to allow applications that don't have explicit rules. The Approval Workflow settings are an atomic unit (not merged from less specific tags). Simply, the most specific tag's value applies. They do not apply to hosts with overridden tags. - **Risk Engine Exceptions** - settings that allow a Santa instance that's a member of a tag to override a risk engine plugin. - **Rules** - policies that describe what software Santa should allow or block. Rules from multiple tags can apply to the same host. When tags define conflicting rules for the same identifier, the most specific / highest precedence tag wins. Admins can apply rules, approval workflow settings and sync settings to tags via the APIs. These won't apply until the tag becomes part of a host's effective tag set, either directly or through group membership. ### Relationships ```mermaid graph LR PU[Primary Users] -->|have one or more| H[Hosts] PU -->|are a member of one or more| G[Groups] H -->|have multiple| T[Tags] G -->|are mapped to one or more| T T -->|have| SS[Sync Settings] T -->|have| AWS[Approval Workflow Settings] T -->|have| R[Rules] T -->|have| REE[Risk Engine Exceptions] ``` ### How Settings and Rules Are Applied Settings and rules are resolved from global (least specific) to the host tag (most specific). When multiple tags define the same setting or a conflicting rule, the setting or rule from the most specific tag wins. Note: Approval workflow settings have additional host-assignment restrictions described above. ```mermaid graph LR A[Global] --> B[Lowest Precedence Tag] --> C[Highest Precedence Tag] --> D["host:<machine_uuid>"] ``` Consider a host with the effective tag order: **global → team-a → host:\** that was applied via group membership. | Setting Type | global | team-a | host:\ | Effective Value | | ---------------------- | ----------- | ------------- | ------------- | --------------- | | **Client Mode** | Monitor | Lockdown | _(not set)_ | Lockdown | | **Blocked Path Regex** | `/tmp/.*` | `/opt/.*` | _(not set)_ | `/opt/.*` | | **Approval Workflow** | _(not set)_ | Self-approval | _(not set)_ | Self-approval | | **Rule for app X** | Block | Allow | _(not set)_ | Allow | | **Rule for app Y** | _(not set)_ | Block | Allow | Allow | ## Tag Creation **Important:** Tags must be created before they can be assigned. You cannot reference a tag that doesn't already exist. To create tags: 1. Navigate to the Tags page. 2. Click "Create tag". 3. Provide a unique name, then click "Create". Once created, the tag appears on the Tags page and can be assigned rules, settings, and hosts. New tags start without a position in the tag order: their rules and settings apply to no hosts until the tag is prioritized. Set a position from the tag's row actions ("Set priority"), or let Workshop prompt you when a change to the tag's rules or settings requires one. Tags can also be created using the [CreateTag](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.CreateTag) RPC. ## Tag Assignment Methods Tags can be applied to hosts via two methods: ### 1. Manual Assignment (Per-Host) — deprecated Host-level tag overrides are deprecated. The Workshop UI no longer offers a way to lock a host's tags, and the `tags` field on [UpdateHost](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.UpdateHost) is deprecated. Prefer a dedicated tag assigned through groups when a host needs specific policy. Hosts that were locked before this change stay locked until an admin returns them to group-derived tags from the host overview ("Use group tags"). Locked hosts cannot participate in approval workflows. ### 2. Automatic Assignment (Via Groups) Tags can be automatically applied to hosts through group membership. This provides scalable tag management across multiple hosts. For a tag applied through groups to take effect, it needs a position in the tag order, which defines the precedence used when tags conflict. Set it from the Tags page using the tag's "Set priority" action; Workshop also prompts for a position when a change would introduce a conflict. Tags can also be ordered with the [UpdateTagOrder](https://buf.build/northpolesec/workshop-api/docs/main:workshop.v1#workshop.v1.WorkshopService.UpdateTagOrder) RPC. ## Group Attachment Methods There are two methods to attach a host to a group (which contains tags): ### Method 1: Directory Sync When directory sync is enabled: - Any group the host's [primary user](https://northpole.dev/configuration/keys#MachineOwner) is a member of will gain access to that group's assigned tags - This provides seamless integration with existing directory structures. - Tag assignment happens automatically based on user group membership ### Method 2: Primary User Groups (Client-Defined) Starting with **version 2025.6** of Santa: - You can define [primary user groups](https://northpole.dev/configuration/keys#MachineOwnerGroups) for a host directly on the client - This method provides more flexibility for environments that aren't using directory sync - Allows manual specification of which groups a host should inherit tags from :::warning If a host has tags manually assigned via primary user groups, that host cannot participate in approval workflows. This is a temporary restriction, future versions of Workshop will enable approval workflows for client defined groups. ::: ### Tag Application Order For both group assignment methods, the total set of tags are applied to the host using the defined tag order. This ensures consistent and predictable tag application across your environment. ## Examples ### Roll Out Lockdown Mode to Sales First An admin wants to move from Monitor mode to Lockdown mode across the fleet but wants to start with Sales (who run a smaller, more predictable set of software) before rolling it out company-wide. 1. Create a **sales-lockdown** tag 2. Map it to the existing Sales IdP group 3. Place **sales-lockdown** above **global** in the tag order 4. Set Client Mode to Lockdown on the **sales-lockdown** tag (global stays in Monitor) 5. Monitor for a week, resolve any issues 6. When confident, change the **global** tag to Lockdown mode and remove the **sales-lockdown** tag ### Different Approval Workflows per Department A company wants Engineering to self-approve low-risk software via the risk engine, while Sales and Finance require manager approval for any new software. 1. Create **engineering**, **sales**, and **finance** tags mapped to their respective IdP groups 2. On the **engineering** tag, configure approval workflows to allow self-approval when the risk engine score is below the threshold 3. On the **sales** and **finance** tags, configure approval workflows to require manager approval for all new software 4. Place all three tags above **global** in the tag order Result: each department gets a tailored approval experience without manual per-host configuration. If a user is a member of multiple groups, the associated tag with the higher precedence wins. ### Incremental Ban of Compromised Software A vulnerability is announced for a widely-used application. The admin wants to block it but roll out the ban gradually to minimize business disruption. 1. Create a **ban-rollout** tag 2. Create an IdP group mapped to that tag 3. Place it above **global** in the tag order 4. Add a block rule for the application to the **ban-rollout** tag 5. Add a small initial set of users to the IdP group 6. (Optional) Trigger a sync 7. Keep adding members incrementally, waiting for issues to surface 8. Once at 100%, move the block rule to **global** and delete the **ban-rollout** tag --- ## Telemetry # Telemetry Workshop provides powerful telemetry capabilities for analyzing Santa security events. The telemetry system integrates with [Santa's telemetry collection](https://northpole.dev/features/telemetry/) to store and enable querying of detailed endpoint activity data in cloud storage buckets. :::info Telemetry is not enabled by default in Workshop. To enable telemetry collection and cloud storage integration, please contact North Pole Security support for configuration assistance. ::: ## Telemetry vs. events Telemetry and [Events](/events) are separate streams. Events are Santa's policy decisions: executions, file access, network flows, mounts and signal reports. They stay small so you can turn them into rules. Every block is uploaded, but an allowed execution is reported at most once per binary per host every 4 hours, so event counts are not execution counts. Telemetry records what the host did, with no policy applied, no throttling and more detail per record. Use it to work out after the fact what happened. The only thing that removes records is a [Filter Expression](/telemetry/filter-expressions) you configured yourself. [Events vs. telemetry](/events#events-vs-telemetry) has the full comparison. ## Querying telemetry ### Table naming convention Workshop uses dynamic table names: | Table Format | Description | Example | | --------------------------- | --------------------------- | ----------------------------- | | `_YYYY` | All events of type for year | `execution_2025` | | `_YYYYMM` | Events for specific month | `execution_202501` | | `_YYYYMMDD` | Events for specific day | `execution_20250125` | | `_YYYY_` | Host-specific events | `execution_2025_a1b2c3d4` | | `_YYYYMM_` | Host & month specific | `execution_202501_a1b2c3d4` | | `_YYYYMMDD_` | Host & date specific | `execution_20250125_a1b2c3d4` | **Event Types**: `execution`, `fork`, `close`, `file_access`, etc. For complete details on event types and their data, see the [Schema](/telemetry/schema) page. :::warning Host ID format When using host UUIDs with dashes in table names, replace dashes with underscores (e.g., `a1b2c3d4-e5f6-g7h8` becomes `a1b2c3d4_e5f6_g7h8`) to avoid SQL syntax errors. ::: ### Schema discovery There is no table catalog. Tables resolve from object storage when a query names one, so `SHOW TABLES` and `information_schema` return no rows. To see a table's columns and types, run `DESCRIBE` on a table name, or `SUMMARIZE` for per-column statistics: ```sql -- Columns and types for one day of execution events DESCRIBE execution_20250125; -- Per-column stats: min, max, approximate unique count, null percentage SUMMARIZE SELECT * FROM execution_20250125 LIMIT 1000; ``` Columns vary across Santa versions, so `DESCRIBE` on your own data is the authoritative list. The [Schema](/telemetry/schema) page documents every event type and field. ### SQL examples ```sql -- Count total execution events this year SELECT COUNT(*) FROM execution_2025; -- Recent execution events for a specific host SELECT * FROM execution_20250125_a1b2c3d4_e5f6_g7h8 LIMIT 10; -- Execution events for a specific binary SELECT * FROM execution_20250125 WHERE Target.Executable.Hash.Hash = 'sha256-hash-here' LIMIT 10; -- Find processes with dangerous entitlements SELECT EventTime, Hostname, Instigator.Executable.Path FROM execution_20250125 WHERE list_contains( list_transform(EntitlementInfo.Entitlements, x -> x.Key), 'com.apple.security.cs.allow-jit' ) LIMIT 10; -- Processes with specific environment variables SELECT * FROM execution_20250125 WHERE list_contains( list_transform(Envs, x -> starts_with(x, 'HOMEBREW_PREFIX=')), true ) LIMIT 10; ``` ## Filtering telemetry on the client Hosts can run CEL expressions to drop or redact events before they are uploaded to your bucket. This is useful for reducing volume, excluding noisy event types, or scrubbing sensitive values like tokens out of events. See [Filter Expressions](/telemetry/filter-expressions) for details. ## Additional resources For detailed information about all event types and their complete schemas, see the [Schema](/telemetry/schema) page. For more information about Santa's telemetry capabilities, visit [Santa's telemetry documentation](https://northpole.dev/features/telemetry/). --- ## Webhooks # Webhooks Workshop can POST events to an HTTPS endpoint of your choosing as they happen, letting you forward Workshop activity into your own systems — SIEMs, ticketing, chat, or custom automation. Each delivery is signed following the [Standard Webhooks](https://www.standardwebhooks.com) specification so your receiver can verify it genuinely came from Workshop. ## Event sources There are three independent webhook sources. Each has its own destination URL, signing secret, and delivery filters, so you can send different event types to different endpoints (or the same one). | Source | Fires when | Filter | | ------------------ | ---------------------------------------------------------------------------------- | ------------ | | Audit events | Any change is made to Workshop (rules, settings, tags, etc.) | Event type | | Signal reports | A detection signal report is first received, and again on each triage state change | Report state | | Software approvals | A piece of software is approved through an approval workflow for the first time | None | ### Audit events Fires once for every audit event — the same record of every change made to Workshop that appears in the audit log, whether the change was made through the UI or the API. See the [Audit documentation](./audit) for the full list of audit event types. By default every audit event is delivered. You can narrow delivery to specific event types; leave the filter empty to deliver all of them. ### Signal reports Fires when a detection signal report is first received from a host (state `NEW`) and again each time a report's triage state changes (for example when it moves to `ACKNOWLEDGED` or `REMEDIATED`). The state filter applies to both cases — receipt counts as the `NEW` state. Leave it empty to deliver for every state. The available states are `NEW`, `ACKNOWLEDGED`, `INVESTIGATING`, `REMEDIATED`, and `DISMISSED`. ### Software approvals Fires once, the first time a given piece of software (a binary or a bundle) is approved through any approval workflow — self-service, designated approver, or social voting. Subsequent approvals of the same software do **not** fire. See the [Approval Workflows documentation](./approval-workflows) for how approvals work. This source has no filter. The payload's `requesting_user` field identifies who requested the software for self-service and designated-approver workflows; it is empty for social voting, which has no single requester. ## Configuring webhooks Navigate to Settings → Webhooks. Each source is configured in its own section with the following fields: 1. **Enable toggle** — turns delivery on or off. Disabling a source stops delivery but keeps its URL and secret so you can re-enable it later without re-entering them. 2. **Destination URL** — the HTTPS endpoint that receives deliveries. 3. **Signing secret** — the key used to sign every delivery (see [Verifying signatures](#verifying-signatures)). It must be at least 24 bytes. 4. **Filter** — event types (audit events) or states (signal reports), where applicable. 5. **Custom headers** (audit events) — additional HTTP headers sent with every delivery, e.g. an `Authorization` header your receiver expects. Click **Save Changes** to apply. Saving requires the `write:settings` permission. :::note The signing secret is **write-only**. Workshop never displays or returns it again after you save it. When editing an existing configuration, leave the secret field blank to keep the current secret — only enter a value when you want to replace it. A source cannot be enabled without a secret, since deliveries can't be signed without one. ::: ## Delivery format Each delivery is an HTTP `POST` with a `Content-Type` of `application/json`. The body is a JSON object with exactly one field set, identifying the source: | Field | Source | | ------------------- | ------------------ | | `audit_event` | Audit events | | `signal_report` | Signal reports | | `software_approval` | Software approvals | Payloads are serialized from Workshop's protobuf definitions, so: - Field names are `snake_case` and enum values are their string names (not numbers). - Every field is emitted even when empty — an unset string is `""`, a number is `0`, a boolean is `false`, an unset nested object is `null`, and an empty list is `[]`. Don't assume a missing field; assume an empty one. The examples below are trimmed to the fields worth highlighting; a real delivery includes the remaining fields at their empty values as described above. ```json { "audit_event": { "id": "0f9c2e6a-1d3b-4a7e-9c2f-8b1a5e6d7c40", "transaction_id": "3a1b8f22-6c4d-4e19-8f2a-1b7c9d0e5a63", "timestamp": "2026-07-20T15:04:05Z", "actor": "user:rah@northpole.security", "event": "AUDIT_EVENT_RULE_UPSERT", "resource": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "outcome": "OUTCOME_SUCCESS", "details": "{\"policy\":\"ALLOWLIST\",\"rule_type\":\"BINARY\"}", "previous_value": "", "ai_chat_conversation_id": "", "via_mcp": false } } ``` The `host` object carries the full set of host fields; only a few are shown here (see the Hosts documentation). ```json { "signal_report": { "id": "6d2f1c88-9a7e-4b31-8c05-2e9f4a1b7d63", "host": { "uuid": "A14A8806-5878-45A2-81E4-DAB36020B560", "serial": "C02XL0ZYJGH5", "hostname": "workstation.example.com", "os_version": "15.5", "primary_user": "jane@example.com", "os_type": "OS_TYPE_MACOS" }, "name": "suspicious_persistence", "severity": "SEVERITY_CRITICAL", "description": "A launch agent was written by an unsigned binary.", "event_ids": ["execution:8f0a1b2c-3d4e-5f60-7182-93a4b5c6d7e8"], "reported_at": "2026-07-20T15:04:05Z", "processed_time": null, "state": "SIGNAL_REPORT_STATE_NEW", "assignee": "", "resolved_by": "", "labels": ["persistence", "unsigned"], "os_type": "OS_TYPE_MACOS" } } ``` For a bundle approval, the `binary` field is replaced by a `bundle` object that nests its constituent binaries. ```json { "software_approval": { "blockable_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "app_name": "Example.app", "approving_user": "approver@example.com", "requesting_user": "requester@example.com", "binary": { "sha256": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "cdhash": "", "signing_id": "com.example.app", "team_id": "EQHXZ8M8AV", "file_name": "Example", "signed_by": [ { "sha256": "b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9", "common_name": "Developer ID Application: Example Inc (EQHXZ8M8AV)", "organization": "Example Inc", "organizational_unit": "", "valid_from": null, "valid_until": null, "signed_by": "", "first_seen": null } ], "entitlements": [], "first_seen": null, "malicious_state": "MALICIOUS_STATE_BENIGN", "bundle_relative_path": "", "signing_timestamp": null, "secure_signing_timestamp": null, "signing_status": "SIGNING_STATUS_PRODUCTION" } } } ``` ### Request headers Every delivery includes the Standard Webhooks headers plus a Workshop `User-Agent`: | Header | Description | | ------------------- | ----------------------------------------------------------------------------------- | | `webhook-id` | Unique identifier for the delivery (see below). Use it to deduplicate. | | `webhook-timestamp` | Delivery time as a Unix timestamp in seconds. | | `webhook-signature` | The signature over the payload (see [Verifying signatures](#verifying-signatures)). | | `Content-Type` | Always `application/json`. | | `User-Agent` | `Workshop/ (+https://northpole.security)` | Any custom headers you configure are added on top of these. The `webhook-id` is namespaced by source so you can tell deliveries apart: | Source | `webhook-id` format | | ------------------ | -------------------------- | | Audit events | `audit:` | | Signal reports | `signal:` | | Software approvals | `software-approval:` | The same `webhook-id` is reused across retries of a delivery, so it's safe to deduplicate on it. ## Verifying signatures Workshop signs every delivery using the [Standard Webhooks](https://www.standardwebhooks.com) scheme. The signature is an HMAC-SHA256 over the string `{webhook-id}.{webhook-timestamp}.{body}`, base64-encoded, and sent in the `webhook-signature` header as `v1,`. The simplest way to verify is with one of the [Standard Webhooks libraries](https://www.standardwebhooks.com), which handle the signature construction and comparison for you — construct a verifier with your configured secret and pass it the raw request body and headers. Note that the standard verification also enforces a **5-minute timestamp tolerance** to guard against replay, so your receiver's clock should be reasonably in sync. :::note The signing secret is the HMAC key. Workshop follows the Standard Webhooks convention: if you enter a base64-encoded value, its decoded bytes are used as the key; if you enter a plain string, the string's raw bytes are used. Standard Webhooks verification libraries expect a base64-encoded secret, so the simplest setup is to generate a random secret, base64-encode it, and use that same string both in Workshop and in your receiver. ::: ## Delivery behavior Deliveries happen asynchronously, off the request path — a webhook failure never blocks or fails the underlying action (for example, an audited change still succeeds even if its webhook can't be delivered). Delivery is best-effort: failures are logged server-side, but there is no delivery dashboard or manual re-drive. - **Retries** — after the initial attempt, a failed delivery is retried up to 5 more times (six attempts in total) with exponential backoff (starting at 100ms, capped at 30s). Retries happen on connection errors, HTTP 429, and 5xx responses. - **Success** — any 2xx response is treated as success. Redirects are not followed. - **Timeouts** — each attempt has its own 30s timeout, and the whole delivery — loading settings, building the client, and all attempts with their backoffs — is bounded to 60s. - **Payload size** — deliveries are capped at 1MB. A payload larger than that is dropped rather than sent. Your endpoint should acknowledge quickly with a 2xx and do any heavy processing asynchronously. ## Security - Destination URLs must use **HTTPS** (plain HTTP is only permitted for `localhost`, for local testing). - URLs may not contain embedded credentials (`user:pass@host`). - URLs that resolve to private, internal, or link-local addresses are rejected to guard against SSRF. Redirects are never followed. - The signing secret is write-only: it is never returned by the API or shown in the UI, and it is stripped from audit log entries. ## Configuring via the API Webhook settings can also be managed through the API: - `GetWebhookSettings` retrieves the current configuration for every source. Requires the `read:settings` permission. The write-only signing secret is never included in the response. - `UpdateWebhookSettings` replaces the configuration for every source. Requires the `write:settings` permission. Because `UpdateWebhookSettings` **replaces** the entire configuration, include every source you want to keep in each call — omitting a source clears it. Sending an empty secret for a source keeps its existing secret rather than clearing it. --- ## API Keys # API Keys The API Keys interface provides a way to create and manage API keys for programmatic access to Workshop. These keys allow automated systems and scripts to interact with the Workshop API without requiring user authentication. Workshop API keys follow a specific format to ensure security and traceability: - Prefix: `npsws_sk_` - Identifies the key as a Workshop secret key - Body: A hexadecimal string that serves as the unique identifier and authentication token API Keys are immutable and cannot be changed after creation. If you need to change a key, you must create a new one. ## Overview The API Key dashboard displays information about each key: - **Name**: The unique name identifier for the API key - **Role**: The permission level assigned to the key (e.g., superadmin, readonly) - **Creator**: The user who created the API key - **Expires**: The expiration date and time of the key ## API Key Roles Workshop supports different roles for API keys: - **superadmin**: Full access to all API endpoints - **readonly**: Read-only access to API endpoints Each role determines which API endpoints the key can access based on the permission requirements defined in the API. ## Creating API Keys To create a new API key, click the "Create" button and fill in the required information: - **Key Name**: A descriptive name for the API key (5-50 characters) - **Role**: The permission level for the key - **Expiry**: How long the key will remain valid (1 week, 1 month, 3 months, or 1 year) ## Managing API Keys The API Keys dashboard allows you to: - **View existing keys**: See all API keys, their roles, creators, and expiration dates - **Delete keys**: Remove API keys that are no longer needed or may have been compromised --- ## RPC Log # RPC Log The RPC Log records every API request the web UI makes during your browser session, so you can inspect exactly what the UI sends and replay any call from the command line. It's an advanced/debugging tool, hidden behind **Developer Mode**. ## Enabling it Open the user menu (your avatar, top right) and turn on **Developer Mode**. An **RPCs** button appears in the header. Turning Developer Mode off hides the button and discards the captured log. ## Using it Click **RPCs** to open the log. Each entry shows the method name, status, and duration; expand one to see: - the **request** and **response** bodies as a color-coded JSON tree - **Copy as cURL** — copies the request as a ready-to-run `curl` command The copied command uses a placeholder API key (`npsws_sk_API_KEY_HERE`) rather than your session credentials — replace it with your own [API key](./api-keys) before running. See the [API overview](.) for the request format. --- ## Bucket Setup # Bucket Setup Binary upload stores files in your own cloud storage bucket. For each request, Workshop mints a short-lived presigned URL, and the host uploads straight to the bucket. Workshop never holds the file. Two providers are supported: Amazon S3 (`s3://`) and Google Cloud Storage (`gs://`). ## Create a bucket Create a private bucket with no public access. Uploaded files are stored at the root of the bucket, keyed by their SHA-256. The Test Bucket check writes objects under a `__workshop_test__/` prefix so they are easy to find and remove. A lifecycle rule that expires `__workshop_test__/` objects is a good safety net in case a cleanup ever fails. ## Grant Workshop access Workshop uses its own cloud credentials, the task role on AWS and the workload service account on Google Cloud. Grant that identity access to the bucket. On S3: - `s3:PutObject` to store uploads. The presigned URL passes this permission to the host. - `s3:DeleteObject` to remove the Test Bucket object after a check. - `s3:ListBucket` to detect the bucket's region. On Google Cloud Storage: - `storage.objects.create` to store uploads. - `storage.objects.delete` to remove the Test Bucket object after a check. - The **Service Account Token Creator** role on the service account itself. Workshop signs the upload URL with the IAM `signBlob` API, which needs this role. Read access is not required. A write-only role is enough. ## Connect the bucket Open **Settings**, find the binary upload section, and enter your bucket URL as `s3://your-bucket` or `gs://your-bucket`. Workshop validates the scheme and bucket name. Clear the field to turn binary upload off. ## Test the bucket Use **Test Bucket** to confirm the setup with an upload and cleanup round-trip. Run it in presigned mode, which exercises the same path real uploads use. The check reports the stage that failed: - **presign**: Workshop could not sign an upload URL. Check credentials, and on Google Cloud the Service Account Token Creator role. - **upload**: the bucket rejected the write. Check the bucket policy and the `PutObject` permission. - **cleanup**: the upload worked but the test object could not be deleted. This is a soft warning. Delete the leftover `__workshop_test__/` object by hand. ## Bucket policy notes Each presigned URL is bound to one exact object key, the file's SHA-256. The host can write that single object and nothing else. Do not require a content type on uploads. Santa does not send one, and a policy that requires a content type will reject every upload. Each URL is valid for a few minutes, so an upload must finish within that window. No CORS configuration is needed, because the upload runs server to server rather than from a browser. ## See Also - [Binary Upload](/binary-upload/) - [Filter Expressions](/binary-upload/filter-expressions) --- ## Filter Expressions # Binary Upload Filter Expressions Binary upload filter expressions are CEL expressions that Santa runs on the host before an upload. A match drops the upload, so the file never leaves the machine. Use them to keep binaries you do not want out of your bucket, such as Apple platform binaries, trusted vendors, or oversized files. Set them with the [`BinaryUploadFilterExpressions`](https://northpole.dev/configuration/keys/#BinaryUploadFilterExpressions) key in the Santa configuration profile, delivered through your MDM. This key is configured in the configuration profile only, not through Workshop. ## How matching works Each expression is checked against the binary's metadata. The first one that returns `true` drops the upload, and the host reports `REFUSED` with the matched expression in the message. If every expression returns `false`, the upload proceeds. An empty or unset list drops nothing, so every requested upload proceeds. There is no built-in default. Expressions that fail to compile, do not return a boolean, or error while evaluating are logged on the host and skipped. The remaining expressions still apply. A typo disables that one expression rather than blocking uploads, so check the host logs after a change. ## The `binary` variable Each expression is evaluated against a single variable named `binary`, the metadata Santa computed for the file. | Field | Type | Description | | --------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `binary.path` | string | Absolute path Santa opened on the host. | | `binary.signing_id` | string | Signing ID. Empty when the file is unsigned or the signature could not be read. | | `binary.team_id` | string | Team ID. Empty when there is none or it could not be read. | | `binary.cdhash` | string | Code directory hash. | | `binary.is_platform_binary` | bool | True for Apple platform binaries. | | `binary.file_size` | int | Size in bytes. | | `binary.macho_type` | string | One of `executable`, `dylib`, `bundle`, `kext`, `other`, or empty for a non-Mach-O file. For a universal binary this reflects the first slice. | ## CEL basics The most useful pieces of the language for filtering: - **Logical**: `&&`, `||`, `!` - **Comparison**: `==`, `!=`, `<`, `>`, `<=`, `>=` - **String functions**: `startsWith()`, `endsWith()`, `contains()`, `matches()`, `size()` Every expression must return a boolean. See [celbyexample.com](https://celbyexample.com) for a full CEL reference. ## Examples ### Skip platform binaries ```cel binary.is_platform_binary ``` ### Skip a Team ID you trust ```cel binary.team_id == "ABCDE12345" ``` ### Skip a vendor's signed apps ```cel binary.signing_id.startsWith("com.microsoft.") ``` ### Skip large files ```cel binary.file_size > 200000000 ``` ### Skip dynamic libraries and bundles ```cel binary.macho_type == "dylib" || binary.macho_type == "bundle" ``` ### Combine conditions ```cel binary.is_platform_binary || binary.file_size > 500000000 ``` ## Testing a change Roll changes out narrowly first. 1. Apply the expression to a small set of test hosts through their configuration profile. 2. Request an upload of a binary you expect to drop and one you expect to keep. 3. Confirm the dropped one returns `REFUSED` with your expression named, and check the host logs for any skipped expressions. 4. Widen to more hosts once it behaves. ## See Also - [Binary Upload](/binary-upload/) - [Bucket Setup](/binary-upload/bucket-setup) - [Telemetry Filter Expressions](/telemetry/filter-expressions) --- ## 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. :::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](/rules/execution-rules#cel-policy-rules). ## 1. How a CEL rule is shaped {#rule-shape} 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 {#execution-context} 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](#caching)). ### 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` | 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` | Command-line arguments. `args[0]` is the launcher's idea of the program name; real flags start at index 1. | | `envs` | `map` | 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` | Parent process chain, immediate parent first, up to `launchd`. Workshop + Santa 2026.2+. | | `fds` | `list` | 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` (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](#recipes). #### `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: ```text 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 {#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](/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](#fallback-rules)). 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: ```cel 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-language} 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 ```cel == != < <= > >= && || ! + - * / % ?: // 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) ```cel 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](#pitfalls)). - `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 ```cel 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. ```cel 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: ```cel // Did any arg match this flag set? args.exists(a, a in ['--inspect', '--inspect-brk', '--remote-debugging-port']) ``` ```cel // Is there a parent that's Slack? ancestors.exists(a, a.signing_id == "BQR82RBBHL:com.tinyspeck.slackmacgap") ``` ### 4.5 Timestamps ```cel 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: ```cel 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](#caching)). ## 5. Fallback rules (Workshop + Santa 2026.3+) {#fallback-rules} 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: ```cel // Block any binary with the hypervisor/virtualization entitlements 'com.apple.security.hypervisor' in target.entitlements || 'com.apple.security.virtualization' in target.entitlements ? BLOCKLIST : UNSPECIFIED ``` ```cel // 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: ```cel 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](/settings#cel-fallback-rules) for configuration. ## 6. The caching model {#caching} 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 (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 {#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](#72-consolidating-multiple-signingids-onto-a-teamid)) to cover a whole vendor. ```cel // 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: ```cel 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: ```cel 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. ```cel // 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`. ```cel '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:** ```cel // 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):** ```cel // 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:** ```cel // 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`: ```cel // 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): ```cel 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):** ```cel args.exists(a, a in ['--global-disable', '--master-disable', '--disable', '--add', '--remove']) ? BLOCKLIST : ALLOWLIST ``` **`security` (keychain dumping and trust anchor manipulation):** ```cel 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):** ```cel // Block password validation and shadow hash reads '-authonly' in args || ('-read' in args && 'dsAttrTypeNative:ShadowHashData' in args) ? BLOCKLIST : ALLOWLIST ``` **`xattr` (quarantine attribute stripping):** ```cel args.join(" ").contains("-d com.apple.quarantine") || '-cr' in args ? BLOCKLIST : ALLOWLIST ``` **Chrome with remote debugging (gate, don't block):** ```cel args.exists(a, a.contains("--remote-debugging-port=")) ? REQUIRE_TOUCHID_ONLY : ALLOWLIST ``` **Electron run-as-Node:** ```cel // 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):** ```cel // 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: ```cel 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 ```cel // Allow only when not running as root euid != 0 ``` ```cel // Or: TouchID for root, normal for everyone else euid == 0 ? REQUIRE_TOUCHID_ONLY : ALLOWLIST ``` ### 7.9 Path-based scoping ```cel // 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. ```cel 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: ```cel // 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:** ```cel // 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. ```cel 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:** ```cel 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:** ```cel 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): ```cel 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 {#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 {#pitfalls} **`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 `` 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 {#quick-reference} ```text // 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 ``` --- ## Execution Rules # Execution Rules The Execution Rules interface provides a comprehensive view of all rules that control execution of binaries across your organization. This centralized dashboard allows administrators to create, monitor, and manage Santa execution rules at scale. ## Overview The Execution Rules dashboard displays information about each rule: - **Identifier**: The unique identifier for the rule (hash, certificate, etc.) - **Comment**: Description or purpose of the rule - **Rule Type**: Type of rule (Binary, Certificate, Team ID, Signing ID, CDHash) - **Policy**: Action to take when the rule is matched (Allow, Block, etc.) ## Rule Types Santa supports several types of execution rules: - **Binary**: Rules based on the cryptographic hash of a binary - **Certificate**: Rules based on the code signing certificate - **Team ID**: Rules based on Apple Developer Team IDs - **Signing ID**: Rules based on signing information - **CDHash**: Rules based on the CodeDirectory hash of a binary ## Rule Policies Each rule can be configured with one of the following policies: - **Allow**: Explicitly allow execution - **Allow as Compiler**: Special rule for compiler processes - **Block**: Block execution. Check **Malicious** when the block is due to malicious content; leave it unchecked for a policy-based block. Block notifications can be silenced — see [Silencing Notifications](#silencing-notifications). - **CEL**: Evaluate a [CEL expression](#cel-policy-rules) to decide at runtime - **Seatbelt**: Require the process to be launched under `santactl sandbox` with a Seatbelt sandbox profile applied. See [Sandbox Rules](/rules/sandbox-rules) ## Silencing Notifications By default the user is notified when a process is blocked. Block rules can silence the GUI dialog and/or the terminal (TTY) message independently: - **GUI**: Suppress the graphical block notification shown to the user - **Terminal (TTY)**: Suppress the message printed to the terminal when a command-line process is blocked Use silencing sparingly — silent blocks give the user no feedback about why a process failed to run, which can be confusing and increase support load. ## Creating Rules To create a new rule, click the "Create Rule" button and fill in the required information: - Rule type - Identifier (hash, certificate, etc.) - Policy action - For **Block** policies, the **Malicious** checkbox (see [Rule Policies](#rule-policies)) and optional **GUI**/**Terminal (TTY)** silence checkboxes (see [Silencing Notifications](#silencing-notifications)) - Optional comment to describe the rule's purpose ### Scoping Rules to Tags Rules can optionally be scoped to one or more tags. When creating a rule, use the tag selector to pick which tags the rule applies to. If no tag is selected, the rule applies globally. To scope a rule to a specific host, check **Show host tags** and search by hostname or primary user — you don't need to know the host UUID. :::warning Rules take effect immediately after creation. Ensure you've verified the identifier before creating a rule. ::: ## Rule Deployment Once created, rules are automatically distributed to Santa clients during their next sync. The timing depends on your sync server configuration. For more detailed information about Santa rules and configuration options, visit the [Santa Documentation](https://northpole.dev/deployment/configuration.html). ## CEL Policy Rules CEL (Common Expression Language) rules provide a flexible, expression-based way to make execution decisions in Santa. Unlike traditional rules that match a single identifier (hash, certificate, etc.), CEL rules evaluate an expression against properties of the binary and its execution context to determine whether to allow, block, or require additional authorization. CEL rules are evaluated by the Santa agent on-device at execution time. Workshop validates CEL expressions before they are saved, ensuring they compile and return a valid decision type. :::info You can test CEL expressions interactively using the [CEL Playground](https://northpole.dev/cookbook/cel-playground) before deploying them as rules. ::: ### Creating CEL Rules CEL rules are created like other execution rules but with the policy set to **CEL** and a CEL expression provided. The expression is validated at creation time and must: - Compile successfully against the CEL environment - Return either a **boolean** or a valid **return value** (see below) - Not reference `UNSPECIFIED` (only fallback rules may use `UNSPECIFIED`) CEL rules can be scoped to tags just like other execution rules. ### Input Variables CEL expressions have access to the following variables, which represent properties of the binary being executed and its runtime context. #### `target` — Executable File Properties The `target` variable contains static properties of the executable file. Rules that only use `target` fields produce **cacheable** results, meaning Santa can remember the decision and skip re-evaluation for subsequent executions of the same binary. | Field | Type | Description | | ---------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------- | | `target.signing_id` | string | The signing ID of the binary, if validly signed | | `target.team_id` | string | The Apple Developer Team ID, if validly signed | | `target.signing_time` | timestamp | Timestamp of when the binary was signed (set by the developer, not verified) | | `target.secure_signing_time` | timestamp | Timestamp certified by Apple's timestamp authority (trustworthy) | | `target.is_platform_binary` | bool | Whether this is an Apple platform binary | | `target.entitlements` | map(string, string) | The binary's entitlements. Values are JSON strings — use `"true"` or `"false"` for boolean entitlements | #### `path` — Executable Path | Field | Type | Description | | ------ | ------ | ---------------------------------------- | | `path` | string | The full resolved path of the executable | Requires Santa 2026.3+. Using this field makes the result **non-cacheable**. #### `args` — Command-Line Arguments | Field | Type | Description | | ------ | ------------ | ---------------------------------------------------- | | `args` | list(string) | The command-line arguments passed to the new process | Using this field makes the result **non-cacheable**. #### `envs` — Environment Variables | Field | Type | Description | | ------ | ------------------- | --------------------------------------------------- | | `envs` | map(string, string) | The environment variables passed to the new process | Using this field makes the result **non-cacheable**. :::info For environment variable keys that contain periods (e.g. `com.apple.dt.Xcode.SourcePathRemapping`), use the `in` operator or bracket syntax instead of the `has()` macro: ```cel // ✅ Correct 'com.apple.dt.Xcode.SourcePathRemapping' in envs envs['com.apple.dt.Xcode.SourcePathRemapping'] == '5' // ❌ Will not compile has(envs.com.apple.dt.Xcode.SourcePathRemapping) ``` ::: #### `euid` — Effective User ID | Field | Type | Description | | ------ | ---- | ------------------------------------------- | | `euid` | int | The effective user ID of the executing user | Requires Santa 2025.12+. Using this field makes the result **non-cacheable**. #### `cwd` — Current Working Directory | Field | Type | Description | | ----- | ------ | ----------------------------------------------------------- | | `cwd` | string | The current working directory of the process being executed | Requires Santa 2025.12+. Using this field makes the result **non-cacheable**. #### `ancestors` — Process Ancestry | Field | Type | Description | | ----------- | -------------- | ------------------------------------------------------------------------------------------------------------ | | `ancestors` | list(Ancestor) | The process ancestry chain. The first element is the immediate parent, followed by its parent, up to launchd | Each `Ancestor` has the following fields: | Field | Type | Description | | ------------ | ------------ | --------------------------------------------------- | | `path` | string | The executable path of the ancestor process | | `signing_id` | string | The signing ID, if validly signed | | `team_id` | string | The Team ID, if validly signed | | `cdhash` | string | The CDHash, if validly signed | | `args` | list(string) | The command-line arguments for the ancestor process | Requires Santa 2026.2+. Using this field makes the result **non-cacheable**. #### `fds` — Open File Descriptors | Field | Type | Description | | ----- | -------------------- | ----------------------------------------------------- | | `fds` | list(FileDescriptor) | The open file descriptors associated with the process | Each `FileDescriptor` has the following fields: | Field | Type | Description | | ------ | ------ | ------------------------------- | | `fd` | int | The file descriptor number | | `type` | FDType | The type of the file descriptor | Available `FDType` values: `FD_TYPE_UNKNOWN`, `FD_TYPE_ATALK`, `FD_TYPE_VNODE`, `FD_TYPE_SOCKET`, `FD_TYPE_PSHM`, `FD_TYPE_PSEM`, `FD_TYPE_KQUEUE`, `FD_TYPE_PIPE`, `FD_TYPE_FSEVENTS`, `FD_TYPE_NETPOLICY`, `FD_TYPE_CHANNEL`, `FD_TYPE_NEXUS` Requires Santa 2026.3+. Using this field makes the result **non-cacheable**. ### Return Values CEL expressions must return either a **boolean** or a **return value enum**. #### Boolean Returns | Value | Decision | | ------- | ------------------------------------------- | | `true` | Equivalent to `ALLOWLIST` — allow execution | | `false` | Equivalent to `BLOCKLIST` — block execution | #### Return Value Enum These values can be used directly as keywords in CEL expressions: | Value | Description | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `ALLOWLIST` | Allow the process to execute | | `ALLOWLIST_COMPILER` | Allow execution and, if transitive allowlisting is enabled, locally allowlist any files created by this binary | | `BLOCKLIST` | Block the process from executing | | `SILENT_BLOCKLIST` | Block execution without showing a GUI notification to the user | | `REQUIRE_TOUCHID` | Hold execution until the user authorizes via biometrics (or password if `EnableStandalonePasswordFallback` is enabled). Requires Santa 2026.1+ | | `REQUIRE_TOUCHID_ONLY` | Like `REQUIRE_TOUCHID` but skips the Santa block dialog and goes straight to TouchID authorization. Requires Santa 2026.1+ | :::warning `SILENT_BLOCKLIST` should be used sparingly — silently blocked applications can be very confusing for users. ::: #### TouchID with Cooldown Instead of returning `REQUIRE_TOUCHID` or `REQUIRE_TOUCHID_ONLY` directly, use the cooldown functions to specify how long the authorization should remain valid: ```cel require_touchid_with_cooldown_minutes(5) require_touchid_only_with_cooldown_minutes(10) ``` The cooldown value (in minutes) controls how long after a successful authorization the user can re-execute the binary without being prompted again. TouchID return values are always **non-cacheable**. Requires Santa 2026.1+. ### Cacheability Santa can cache the result of a CEL expression to avoid re-evaluating it on every execution of the same binary. A result is **cacheable** only when the expression exclusively uses static `target` fields. Using any of the following makes the result **non-cacheable**: - `args`, `envs`, `euid`, `cwd`, `ancestors`, `path`, `fds` - `REQUIRE_TOUCHID`, `REQUIRE_TOUCHID_ONLY`, or the cooldown functions Workshop displays a cacheability indicator when creating or editing CEL rules so you can understand the performance implications of your expression. ### CEL Fallback Rules In addition to standard CEL rules, CEL expressions can be configured as **fallback rules** in Sync Settings. Fallback rules are evaluated when no other rule matches and can optionally return `UNSPECIFIED` to pass the decision to the next fallback rule in the chain. CEL fallback rules require Santa 2026.3 or later. Key differences from standard CEL rules: - Configured per-tag in Sync Settings (not as standalone rules) - May return `UNSPECIFIED` to defer the decision - Maximum of 10 fallback rules per sync setting - Can include a custom message and URL shown to the user on block ### Minimum Santa Version Requirements Workshop automatically calculates the minimum Santa version required for a CEL rule based on the variables and functions it uses: | Feature | Minimum Version | | ------------------------------------------------------------- | --------------- | | Basic CEL (target fields only) | No minimum | | `euid`, `cwd` | Santa 2025.12+ | | `REQUIRE_TOUCHID`, `REQUIRE_TOUCHID_ONLY`, cooldown functions | Santa 2026.1+ | | `ancestors` | Santa 2026.2+ | | `path`, `fds` | Santa 2026.3+ | Hosts running older versions of Santa will not evaluate rules that use features they don't support. ### Available Operations CEL expressions support standard CEL operations including (see [celbyexample.com](https://celbyexample.com) for a full reference): - **Comparison**: `==`, `!=`, `<`, `>`, `<=`, `>=` - **Logical**: `&&`, `||`, `!` - **Arithmetic**: `+`, `-`, `*`, `/`, `%` - **Ternary**: `condition ? value_if_true : value_if_false` - **Membership**: `in` - **String functions**: `startsWith()`, `endsWith()`, `contains()`, `join()`, `size()` - **List macros**: `exists()`, `all()`, `filter()`, `map()`, `size()` - **Optional field access**: `has()` - **Timestamp**: `timestamp()` for constructing timestamps for comparison ### CEL Examples #### Allow binaries signed after a specific date ```cel target.secure_signing_time > timestamp('2025-01-01T00:00:00Z') ``` #### Block a binary when run with a specific argument ```cel '--inspect' in args ? BLOCKLIST : ALLOWLIST ``` #### Allow only when launched from a specific parent process ```cel ancestors.exists(a, a.signing_id == 'platform:com.apple.Terminal') ``` #### Block when running as root from a non-standard directory ```cel euid == 0 && !cwd.startsWith('/Applications') ? BLOCKLIST : ALLOWLIST ``` #### Block hypervisors ```cel target.entitlements.exists(k, k == 'com.apple.security.hypervisor' || k == 'com.apple.security.virtualization') ? BLOCKLIST : UNSPECIFIED ``` This example uses `UNSPECIFIED` and is only valid as a **fallback rule**. To create exclusions, allowlist a specific virtualization tool by Team ID or Signing ID — the allowlist rule will take priority over this fallback rule. #### Check for specific file descriptor types ```cel fds.exists(f, f.type == FD_TYPE_SOCKET) ? BLOCKLIST : ALLOWLIST ``` #### Check environment variables with fallback ```cel 'DEVELOPER_MODE' in envs && envs['DEVELOPER_MODE'] == '1' ? ALLOWLIST : BLOCKLIST ``` --- ## File Access Rules # File Access Rules File Access rules enable Santa to control which processes can read and write files on macOS systems. This powerful feature allows administrators to monitor, log, and block file access attempts based on flexible policies. :::info Requirements File Access rules require macOS 13 or later. ::: ## Overview File access authorization provides fine-grained control over file system access by allowing you to: - Log access events for audit and compliance - Block unauthorized access to sensitive files - Define policies based on both files and processes File Access rules configure the policies that Santa uses to decide which files and processes to monitor and control access to. ## Rule Types File Access supports four distinct rule types, categorized by orientation: ### Data-Centric Rules These rules focus on protecting specific files or directories: #### Paths with Allowed Processes Specifies which processes are allowed to access particular files or directories. Only the listed processes can access the protected paths, while all others are denied. **Use case**: Protecting sensitive configuration files by allowing only specific system processes to access them. #### Paths with Denied Processes Blocks designated processes from accessing specific files or directories. All processes except those listed are allowed to access the paths. **Use case**: Preventing a particular application from accessing user documents or sensitive data. ### Process-Centric Rules These rules focus on controlling what a process can access: #### Processes with Allowed Paths Defines which paths a process is allowed to access. The process can only access the specified paths and is denied access to all others. **Use case**: Sandboxing an untrusted application to only access specific directories. #### Processes with Denied Paths Restricts a process from accessing specific paths. The process can access anything except the denied paths. **Use case**: Preventing an application from accessing system directories or other users' home folders. ## Rule Options ### Allow Read Access When checked, read access will be allowed. ### Block Violations When unchecked, this rule will be in 'audit-only' mode, where violations will trigger events to be sent to Workshop but the access will not be blocked. This is useful for testing policies before enforcement. ## Silencing Notifications By default the user is notified when access is blocked. Block rules can silence the GUI dialog and/or the terminal (TTY) message independently: - **GUI**: Suppress the graphical block notification shown to the user. This can be useful for preventing background processes from accessing files without interrupting the user, but care should be taken not to use this in cases where the user is expecting the access to work. - **Terminal (TTY)**: Suppress the message printed to the terminal when a command-line process is blocked. Use silencing sparingly — silent blocks give the user no feedback about why an access failed, which can be confusing and increase support load. ## Per-Process Overrides Every option above applies to the whole rule. A per-process override changes those options for a single process in the rule's process list, without splitting the rule in two. The most common use is a **silent deny**: a background process that constantly touches a protected path is blocked, but the user is never notified. This keeps the allow list short without drowning the user in notifications they cannot act on. Each process row has an Overrides control that opens a panel. The panel is the only place per-process overrides are set, and it always offers an Action. Under the two path-centric rule types it also mirrors the rule's own settings; the process-centric types offer the Action alone (see "Which settings each rule type offers" below). The cog on the collapsed row is marked when the process carries any override, and hovering it summarizes them, so an overridden process is visible without opening it. The Action list always starts with whatever the rule itself would do to a process it lists, named for that outcome rather than labelled "rule default": | Rule type | First option | | ---------------------------- | -------------------------------------------- | | Paths with allowed processes | Allow | | Paths with denied processes | Deny, or Audit while Block Violations is off | | Processes with allowed paths | Restrict to these paths | | Processes with denied paths | Block from these paths | The process-centric types have no single equivalent action — being governed by them is path-scoped — which is why their first option is worded rather than named after an action. The remaining options are the concrete actions: | Action | Effect | | ---------------------------- | -------------------------------------- | | Allow, or "Exempt from rule" | See "What allow means" below. | | Audit | Allowed, but every access is recorded. | | Deny | Blocked. | Whichever concrete action the first option already stands for is not listed twice. Leaving a process on that first option records no override for it, so the process keeps following the rule if you later change the rule type or turn Block Violations off. Choosing an action that matches what the rule already does is treated the same way, since it says nothing the rule does not already say. ### Which Settings Each Rule Type Offers Under the two **path-centric** rule types, the panel also overrides the rule's settings for that one process: allow read access, silent mode, silent TTY mode, block message, event detail URL, and event detail text. Anything left on "inherit" uses the rule's own value. These settings only appear when the process ends up **denied** — either because its Action is Deny, or because the rule's own outcome for a listed process is a deny. Every one of them describes a block: what to silence, whether reads are covered, and what the user is shown. While the access is permitted there is nothing for them to change, so the panel offers the Action alone. Turning Block Violations off on a deny-list rule therefore hides them, because the rule then records rather than blocks. The two **process-centric** types offer the Action only, since these settings carry far less weight there. Whenever a setting's control is not shown — for either reason — a value already set through the API or a rule pack still applies. The form lists those values in the panel with a button to clear them, rather than hiding a setting that is still in force. Note that silencing is **not** an action. To block a process without notifying the user, set Action to Deny and Silent mode to On. They are separate because they are separate settings on the rule too, and folding them into one option would mean picking an action silently rewrites your notification settings. Because a silently denied process gives the user no feedback at all, document why the override exists. A user who runs the blocked operation deliberately has no way to discover that the rule is the cause. ### What Allow Means Allow does not mean the same thing in both families of rule type, so Workshop labels it differently in each. Under the two **path-centric** types (paths with allowed processes, paths with denied processes), the process list decides whether a process may reach the paths. Allow there means what it says: permit this process's access. Under the two **process-centric** types (processes with allowed paths, processes with denied paths), the process list names the processes the rule governs, and the _paths_ carry the allow/deny sense. Allowing a process there does not permit one access — it releases the process from the rule entirely, so the rule never denies it, wherever it goes. Workshop labels this **Exempt from rule** to keep the two apart. ### Staging a Rollout A per-process override has no separate audit-only switch; it inherits the rule's Block Violations setting. Set a process to **Audit** to get audit-only behavior for that one process while the rest of the rule enforces. This makes a staged rollout expressible in a single rule: turn Block Violations on, then set the processes you are still unsure about to Audit. They keep generating events without blocking anyone, and you move them back to the rule's own outcome one at a time as the events come back clean. ### Denying Reads as Well as Writes A process set to **Deny** can still read, unless its Allow read access is also off. This catches people out because the rule-level Allow read access defaults to on, and an unset per-process override inherits it. To deny a process both reads and writes, set Action to Deny **and** set Allow read access to Off in that process's overrides panel. Workshop flags the combination inline when a deny would otherwise still permit reads. ### Santa Version Requirements Per-process overrides need **Santa 2026.8 or newer**. Older agents are still sent the rule, but they cannot apply a per-process override. All Workshop can do is choose whether to list the process at all, so only the allow/deny dimension survives, and it survives differently in each family. Under a **path-centric** rule, the process list is the verdict, so a process is sent only when plain membership is the outcome the override asks for. A silently denied process under a "paths with allowed processes" rule is left out, which makes the rule's handling of an unlisted process apply: still blocked, but the user gets the notification. Under a **process-centric** rule, the list decides whether the rule governs the process at all, so the fallback flips. A denied process stays in the list, because withholding it would leave an older agent applying no restriction to it whatsoever. An exempted process is withheld, which is exactly what exempting it means. A process set to **Audit** is withheld too: an older agent cannot record an audited access, so the closest it can manage is to stop applying the rule to that process. :::warning Two overrides cannot reach an older agent at all Membership carries no settings, so these are silently lost below Santa 2026.8 and the agent uses the rule's own value instead: - **Deny under an audit-only rule.** With Block Violations off, an older agent audits the access rather than blocking it. The override enforces only on 2026.8 and newer. - **Allow read access set to Off.** An older agent keeps the rule's value, so the process can still read. If either matters for a rule, check your fleet's Santa versions before relying on it. ::: ## Process Overrides From Tag Settings {#process-overrides-from-tag-settings} The per-process overrides above belong to one rule. [Process overrides in Sync Settings](/settings#process-overrides) reach every rule: at sync time, Workshop adds them to each path-centric rule the host downloads. You list a platform binary or an organization-wide deny once, instead of in every rule. Process-centric rules never receive these entries. Entries can overlap: a Team ID Allow and a Signing ID Deny can both match one process. When they do, the Deny wins, because entries are applied Deny first, then Audit, then Allow. On a paths-with-denied-processes rule, any process not listed is already allowed, so Allow entries are skipped there. If a rule already lists a process that tag settings also name (same identifier type and value), the rule's own entry applies and the tag-settings entry does not. This holds even for a Deny: listing a process in a rule exempts it from an organization-wide Deny, for that rule only. The rule preview and the details page label these entries "Overridden by this rule". :::note The preview resolves overrides against the rule's own tag and Global. A host that carries a higher-precedence tag with its own override list receives that list instead, so what one host downloads can differ from the preview. ::: ### Opting a Rule Out Every path-centric rule has an **Apply process overrides** checkbox, checked by default. Uncheck it to keep all entries from tag settings out of the rule. There is no partial opt-out: the rule loses the Deny entries along with everything else. Rules materialized from rule packs cannot opt out. ### Agents Older Than Santa 2026.8 Older agents cannot apply per-process options. If a rule carries any Deny entry, its own or from tag settings, older agents receive it without the tag-settings Allow and Audit entries. An older agent can block more than a newer one, never less. ## Paths File Access supports flexible path matching, using either literals (with optional wildcards) or prefixes. Some rules to be aware of: - All paths are case-sensitive - Paths must reference resolved filesystem locations - Symbolic links are not supported - use the actual resolved path - Always use absolute paths, not relative paths ### Path Literals Specify exact file or directory paths: ``` /etc/sudoers /Users/admin/.ssh/id_rsa ``` You can also use standard wildcards for pattern matching: ``` /Users/*/Documents/* /Applications/*.app ``` Standard libc `glob(3)` patterns are supported (excluding extended patterns like `**`): ``` /etc/*.conf /var/log/app-[0-9]*.log ``` ### Path Prefixes Enable recursive directory monitoring: ``` /Users/admin/Documents/ ``` ## Processes Processes can be matched using multiple identification methods, for flexibility when writing rules. You should use signing identifiers (Signing ID, Team ID, CDHash) rather than file paths, whenever possible. File paths can easily be changed, while code signing identifiers provide stronger security guarantees. ### Binary Paths Match by full executable path: ``` /Applications/TextEdit.app/Contents/MacOS/TextEdit ``` ### Signing ID The code signing identifier assigned to the application, prefixed with the Apple developer team ID of the organization that signed it: ``` ABCDE12345:com.example.myapp ``` Use the special team ID `platform` for platform binaries that are part of the OS: ``` platform:com.apple.less ``` ### Team ID The Apple Developer Team ID: ``` ABCDE12345 ``` ### CDHash The CDHash of the signed binary: ``` a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0 ``` ### Leaf Certificate Hash The SHA-256 hash of the leaf certificate that was used to sign the binary: ``` 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef ``` ## Best Practices ### Start with Audit-Only Monitoring Before enforcing rules, enable audit-only mode to: - Understand normal access patterns - Identify legitimate processes that need access - Avoid accidentally blocking critical system operations ### Maintain Specific Path Patterns Use the most specific path patterns possible: - Avoid overly broad wildcards like `/*` - Target specific directories or file types - Use prefix matching judiciously for large directory trees ### Test Thoroughly Before deploying to production: - Test rules on non-critical systems first - Monitor logs for unexpected denials - Verify legitimate operations still work - Check for performance impact ## Related Documentation For more detailed information about file access authorization configuration and examples, visit the [Santa File Access Documentation](https://northpole.dev/features/faa/). --- ## Network Rules # Network Rules Network rules let Santa authorize, deny, or audit network connections on macOS hosts. Rules match flows by the local process, the remote peer, and the transport (ports, protocols, direction), and apply an action when a flow matches. :::info Requirements Network rules require Santa's network extension, which is gated behind the network extension tenant feature. The **Network** rules tab and the `CreateNetworkFlowRule` API are unavailable until it is enabled. ::: ## Overview Each rule is scoped to a [tag](/tags) and identified by a `name` that is unique within that tag. A rule describes: - **Who** — the local process initiating or receiving the flow - **Where** — the remote peer (hostname, domain, or IP/CIDR) - **How** — the transport: port ranges, IANA protocol numbers, and direction - **What to do** — the action to take when a flow matches Flows that match no rule fall back to the host's effective [default action](#default-action). ## Actions | Action | Behavior | | --------------- | ------------------------------------------------ | | **Allow** | Allow matching flows. | | **Deny** | Deny matching flows and notify the user. | | **Silent Deny** | Deny matching flows without notifying the user. | | **Audit** | Allow matching flows, but log them for auditing. | ## Direction | Direction | Matches | | ------------ | ----------------------------------------------- | | **Any** | Both incoming and outgoing flows (the default). | | **Outgoing** | Flows initiated by the host. | | **Incoming** | Flows initiated by a remote peer. | A direction-specific rule matches only that direction. In particular it does **not** match a flow whose direction is unknown. ## Matchers A rule must specify at least one **process** or **remote** matcher. Ports, protocols, and direction alone would match every flow; an intentional match-everything policy belongs in the [default action](#default-action), not in a rule. Within a single field, multiple values are OR'd (any value matches). Across fields, conditions are AND'd (every populated field must match). ### Process matchers Identify the local process by code signing identity. - **CDHash** — the code directory hash of a specific binary. - **Signing ID** — the signing identifier, prefixed with the Team ID (`ABCDE12345:com.example.app`), or `platform:` for platform binaries. - **Team ID** — the Apple Developer Team ID (`ABCDE12345`). ### Remote matchers Identify the remote peer: - **Hostname** — an exact hostname (`api.example.com`). - **Domain** — a domain that matches itself and its subdomains (`example.com` also matches `api.example.com`). - **Address** — an IP address or CIDR prefix (`10.0.0.1`, `10.0.0.0/8`, `2001:db8::/32`). ### Transport matchers - **Ports** — single ports (`443`) or inclusive ranges (`8000-8080`), each between 1 and 65535. Empty matches any port. - **Protocols** — IANA protocol numbers 0–255 (6 = TCP, 17 = UDP, 1 = ICMP, 58 = ICMPv6). Empty matches any protocol. ## Default action `network_flow_default_action` is a per-tag network extension setting that decides flows no rule matches. It is the right place for a broad default-allow or default-deny posture; individual rules then carve out the exceptions. When a host carries several tags that each set a default action, the highest-priority tag's setting wins (see [Tags](/tags)). ## Precedence Two independent precedence systems apply, in order. First Workshop decides **which** rule to send to a host when the same rule name exists across several of the host's tags ([tag precedence](#tag-precedence)). Then, on the host, Santa's network extension (`santanetd`) decides **which** of the rules that match a given flow wins ([on-host evaluation precedence](#on-host-evaluation-precedence)). ### Tag precedence Rule names are unique per tag, but a host usually carries several tags, and the same name may be defined on more than one of them. Santa keys network rules by `name` (a stable slot), so Workshop resolves each name to a single winner before syncing it to the host. A host's tags are ordered by the admin-configured **tag order**, highest priority first (see [Tags](/tags)). For a given name, the rule on the highest-priority tag wins and is the one sent to the host. ### On-host evaluation precedence For a single flow, `santanetd` collects **every** rule whose conditions all hold, then picks the strongest by comparing this tuple field-by-field (earlier fields dominate; on a tie it moves to the next): 1. **Priority / rank** — a rule marked `priority` outranks every ranked rule. Otherwise the higher `rank` wins; an unranked rule is rank 0. `priority` and `rank` are mutually exclusive. 2. **Process specificity** — of the matched process key: CDHash > Signing ID > Team ID > any-process. A rule is ranked by the key it actually matched through, not its most specific declared key. 3. **Remote tier** — exact IP > CIDR > hostname > domain > any-remote. 4. **Within-tier specificity** — a longer CIDR prefix or deeper domain label depth wins (the more specific match). 5. **Port narrowness** — the narrowest matching port range wins. 6. **Protocol-specific** — a rule that names protocols beats one that does not. 7. **Direction-specific** — a rule that names a direction beats one that does not. 8. **Action class** — deny > silent-deny > allow > audit. 9. **Rule ID** — final tiebreak: the higher (newer) rule ID wins. The winning rule's action becomes the flow's verdict; the other matched rules are recorded as competing rule IDs in telemetry ("why didn't my rule win?") but do not affect enforcement. If no rule matches, the [default action](#default-action) decides. ## Best practices - **Start with Audit.** Use the Audit action (or a default-allow posture) to see which flows your rules would match before you enforce a denial. - **Match on signing identity.** Prefer CDHash / Signing ID / Team ID over matching broad remote ranges; process identity is the strongest signal. - **Be specific.** More specific matchers (exact IP, longer CIDR, named ports) win under on-host precedence, so narrow rules reliably override broad ones. - **Use tags deliberately.** Put broad defaults on low-priority tags and exceptions on higher-priority tags. --- ## Package Rules # Package Rules A package rule targets a piece of software by name in a package catalog (for example a Homebrew cask or an npm package) rather than by a raw hash or Team ID. Workshop resolves the package to its concrete identifiers and **materializes** ordinary execution rules from them. As new versions are published, Workshop re-resolves the package and adds rules for them. When a version or binary no longer passes your [CEL filters](#advanced-cel-filters), Workshop removes its rules. You manage one package rule instead of a growing list of hashes. See [How rules stay in sync](#how-rules-stay-in-sync) for what each sync adds, updates, and removes. One package rule can cover many versions and, depending on the rule type, many binaries per version. That reach is what makes the filters below useful: they let you narrow a broad package rule down to exactly the versions and binaries you want to trust. :::info Package rules are a licensed feature. If the package rule form isn't available, talk with us to get access. ::: ## Creating a package rule Open **Rules → Package Rules → New Package Rule**. A package rule has a few core fields plus the optional filters described later. ### Package source and name Pick the **Package Source** that hosts the software, then enter the **Package Name** as it appears in that catalog: | Source | Name example | | ---------------- | --------------------- | | Homebrew | `wget` (formula) | | Homebrew Cask | `firefox` (cask) | | NPM | `express` | | GitHub | `owner/repo` | | Rust (crates.io) | `rustls` | | VS Code | `publisher.name` | | Terraform Plugin | `hashicorp/aws` | | URL | a direct download URL | | Nix | `ripgrep` (nixpkgs) | You can paste several names at once, such as the output of `brew list`. Each name becomes its own package rule, with the same source, policy, tags, and filters. Workshop looks the package up in the catalog and reports how many execution rules it will create so you can see the reach before saving. When you add more than one name, that estimate is skipped. Catalogs often keep old names as aliases: `pkg-config` and `pkgconf` are the same Homebrew formula. Workshop keeps the name you entered and shows the name it resolves to next to it, on both the package rules list and the rule's detail page. ### Tags Package rules respect the same tag scoping as other rules. Leave tags empty to apply the rule everywhere, or add one or more tags to limit it to the hosts that carry them. ### Policy The **Policy** decides what the materialized execution rules do: - **Allow**: permit the package's binaries to run. - **Allow as Compiler**: allow the binaries and treat them as trusted compilers for transitive allowlisting. - **Block**: prevent the package's binaries from running. - **CEL**: evaluate a CEL expression at execution time to decide allow or block. For **Block** and **CEL** policies you can set an optional **Custom Block Message** (shown to the user when execution is blocked, HTML supported) and a **Custom URL** for a help or appeal link. ### Preferred rule type A package rule materializes into execution rules of the type you pick under **Preferred Rule Type**: | Rule type | Materializes to | Per-binary filter | | ----------- | ----------------------------- | ----------------- | | Binary | one rule per binary | available | | CDHash | one rule per binary | available | | Signing ID | one rule per signing identity | not applicable | | Team ID | one rule per signing identity | not applicable | | Certificate | one rule per signing identity | not applicable | **Binary** and **CDHash** are the only types that expose per-binary identifiers, so the [binary-selection filter](#binary-selection) is available only for them. The signing-identity types cover every binary signed with that identity, so there is nothing per-binary to filter. ## Simple filters Every package rule can be narrowed with the built-in filters, all optional: - **Min Release Date** and **Max Release Date**: keep only versions released within a date window. - **Version Filter**: an RE2 regular expression matched against the version string (for example `^1\.` to pin to the 1.x series). These filters decide which versions Workshop asks the catalog about, so they control what gets added. If you narrow a simple filter later, rules that were already materialized stay in place. To make narrowing remove existing rules, use the [version selection](#version-selection) CEL filter instead. For example, `version.startsWith("1.")` as a version selection filter removes rules for versions outside the 1.x series, while `^1\.` as a Version Filter only stops adding them. ## Advanced CEL filters {#advanced-cel-filters} Two optional [CEL](/rules/cel-guide) expressions give you finer control than the simple filters. Open **Advanced (CEL Filters)** in the package rule dialog to set them. Both filters: - Must evaluate to a **boolean**. `true` keeps the version or binary, `false` drops it. - Remove rules as well as add them. If a version or binary that was covered stops passing a filter, Workshop removes its rules on the next sync. A rolling window such as `version_rank <= 5` retires old versions as new ones ship. - Are **ANDed** with the simple filters above and with each other. A version or binary is covered only if every filter that applies to it returns `true`. - Are evaluated by **Workshop when it materializes the rule**, not by Santa at execution time. This is a different, smaller surface than the execution-context CEL described in the [CEL Guide](/rules/cel-guide): there is no execution context here, so no `target.*` signature fields, `args`, `ancestors`, or return-value keywords like `ALLOWLIST`, only the variables listed below and a boolean result. (The `target` variable in the version filter below is unrelated to execution CEL's `target`: here it is a plain build-target string.) ### Version selection The **Version Selection** filter runs for each build of each version. A version can ship several builds, one per platform target, and `target` lets you filter those individually. Use it to soak-test new releases, keep only the newest few versions, or drop builds for platforms you don't ship. Variables: | Variable | Type | Description | | -------------------- | ----------- | ------------------------------------------------------ | | `version` | `string` | The version string, e.g. `1.25.0` | | `released_at` | `timestamp` | Upstream release date of this version | | `target` | `string` | Build target, e.g. `arm64_tahoe` | | `latest_released_at` | `timestamp` | Release date of the newest version in the filtered set | | `version_rank` | `int` | `1` for the newest version, increasing for older ones | | `version_count` | `int` | Number of versions in the filtered set | `version_rank`, `version_count`, and `latest_released_at` are computed over the versions that already passed the simple filters (date window and version regexp), not the entire upstream catalog. Tightening a simple filter changes all three. Helper functions (the same relative-time helpers used elsewhere in Workshop CEL): ```cel now() // the current time today() // the current date at UTC midnight days(N) // a duration of N days ``` Use `now() - days(30)` for relative windows. CEL's `duration()` literal does not accept a day suffix (`duration("30d")` is invalid), so build day-scale durations with `days(N)`. Examples: ```cel // Soak new releases: only trust versions at least 30 days old. released_at < now() - days(30) ``` ```cel // Keep only versions released within 60 days of the newest one. released_at >= latest_released_at - days(60) ``` ```cel // Keep only the five newest versions. version_rank <= 5 ``` ```cel // Drop a build target you don't deploy. !target.contains("bigsur") ``` ```cel // Only versions released in roughly the last year. released_at > today() - days(365) ``` ### Binary selection The **Binary Selection** filter runs once per binary within a matched version. It is available only for the **Binary** and **CDHash** rule types, which create one rule per binary. Use it to allow a package's main executable while excluding the bundled helper binaries it ships. Variables: | Variable | Type | Description | | -------- | -------- | -------------------------------------------------------------------------------- | | `path` | `string` | Path of the binary inside the package, e.g. `Firefox.app/Contents/MacOS/firefox` | | `hash` | `string` | SHA-256 hash of the binary | | `cdhash` | `string` | Code directory hash of the binary | Paths are **package-relative**: they include the bundle root (for example `Firefox.app/Contents/MacOS/firefox`, or `wget/1.25.0/bin/wget` for a formula), not a path anchored at `Contents/`. Match with `contains` and `endsWith` rather than `startsWith` so a rule keeps working regardless of the bundle name: ```cel // Exclude bundled helper binaries (auto-updaters, embedded frameworks, etc.). !path.contains("/Contents/Frameworks/") ``` ```cel // Allow only the app's main executable. path.endsWith("/Contents/MacOS/firefox") ``` ## How rules stay in sync Workshop re-resolves a package rule when you create or edit it, when the catalog reports a change to the package, and on a timer every 25 to 35 minutes. Use the **Sync Execution Rules** action on a package rule to run a sync now. Each sync runs three stages: 1. **Remove.** Workshop checks the rules the package rule already manages against the preferred rule type and the CEL filters. Rules that no longer pass are removed. 2. **Filter.** The catalog's current list of versions goes through the same filters. 3. **Add and update.** Rules for anything in that list that isn't materialized yet are added. Rules whose policy no longer matches the package rule are updated. This stage never removes a rule. In practice: - A version the catalog no longer reports keeps its rules. Catalogs re-index packages and purge old history, so a missing version is not a reason to remove rules. To remove every rule a package rule created, delete the package rule and check **Also delete all execution rules associated with this package**. - A policy change applies to every rule the package rule manages, including rules for versions the catalog no longer reports. - Narrowing a [simple filter](#simple-filters) stops adding rules but does not remove existing ones. Narrowing a [CEL filter](#advanced-cel-filters) does both. - If you change the preferred rule type, rules for the versions the catalog currently reports are replaced with the new type. Rules for versions it no longer reports keep the old type. Materialized rules are ordinary execution rules and are enforced by Santa like any other. See [Rule Packs](/rules/rule-packs) for the related mechanism that materializes a curated set of rules maintained by North Pole Security. --- ## Rule Packs # Rule Packs Rule Packs are curated sets of rules, maintained by North Pole Security, that you can subscribe to instead of authoring and maintaining the rules yourself. When you subscribe a tag to a pack, the pack's rules are copied in as ordinary, editable Workshop rules and kept in sync as the pack is updated. Each pack targets a common need, such as allowing a popular developer toolchain or a well-known vendor's software. You can adopt a vetted baseline quickly and adjust it to fit your environment. :::info Rule Packs are a licensed feature. If you don't see the **Packs** tab populated on the Rules page, talk with us to get access. ::: ## How Rule Packs Work When you subscribe a tag to a pack, Workshop **materializes** each rule in the pack into a normal Workshop rule (an execution, file-access, or package rule, depending on the pack). One copy of each rule is created per tag you subscribe. Materialized rules are regular Workshop rules in every respect: - They appear in the usual rule tables (Execution, File Access, Package, etc.) and are enforced by Santa like any other rule. - They can be searched, filtered, and edited. - They are scoped to the tag (or tags) you subscribed. Because the pack's rules become first-class Workshop rules, there is no separate enforcement path to learn. A pack is just a convenient way to add and maintain a set of rules in bulk. ## Subscribing to a Rule Pack 1. Go to **Rules** and open the **Packs** tab. 2. Browse or search the catalog and find the pack you want. 3. Click **Add** on the pack's card. 4. Choose one or more tags to subscribe. The pack's rules are materialized once under each selected tag. 5. Confirm with **Add Rule Pack**. After subscribing, open the subscription from its card to see the rules the pack created. ## Editing Materialized Rules We encourage you not to edit rules created by a rule pack. Workshop can recreate or update those rules whenever a pack is added, updated, or removed, so local changes may not stick. If you do edit a rule created by a rule pack, it loses its provenance: Workshop no longer remembers that the rule came from the pack. The rule keeps existing as a standalone Workshop rule, which may or may not conflict with future rule pack updates. ## Keeping a Pack Up to Date North Pole Security publishes new versions of packs over time, for example to add coverage for a new release of an application. Workshop periodically checks for new versions and flags a subscription when an update is available. Updates are always **admin-initiated**. Workshop never adopts a new version automatically. To apply an update: 1. Open the subscription that shows **Update available**. 2. Review the diff between your current rules and the new version. 3. Apply the update. Workshop re-materializes the pack's rules to match the new version. ## Unsubscribing Unsubscribing a tag from a pack removes the subscription and deletes the rules it materialized for that tag, all in a single step. Rules you created yourself are left untouched. ## Packs Withdrawn Upstream If North Pole Security removes a pack you're subscribed to, the subscription is marked **No longer available**. The rules it already materialized keep enforcing and are never silently dropped, but updates are disabled. Unsubscribe if you no longer need those rules. ## Auditing Subscribing, applying an update, and unsubscribing each record audit events, including per-rule events linked to the action, so you have a complete trail of how a pack changed your rules. See [Audit](/audit) for more on audit events. --- ## Sandbox Rules # Sandbox Rules A Sandbox rule is an [execution rule](/rules/execution-rules) whose policy is **Seatbelt**. Instead of allowing or blocking the binary outright, it requires the binary to be launched through `santactl sandbox` (abbreviated `santactl sb`), which applies a macOS Seatbelt sandbox profile carried by the rule before executing it. :::info Requirements Santa gained sandbox support in **2026.5**, but policies saved by Workshop use the `BINARY_PATH` parameter, which Santa only understands from **2026.6**. Every Seatbelt rule is therefore stamped with a minimum version of 2026.6, and hosts on anything older will not receive it. The feature is currently in beta. ::: ## Overview Seatbelt is the macOS sandbox. Profiles are written in **SBPL** (Sandbox Policy Language), a Scheme-like language that names the operations a process may perform and the arguments those operations are allowed to take. A Sandbox rule pairs a normal execution rule identifier (CDHash, Signing ID, Team ID, Certificate or Binary hash) with an SBPL profile: - Running the binary directly is **blocked**, with the reason "… (requires running under `santactl sandbox`)". - Running it as `santactl sandbox [args…]` is **allowed**, with the profile applied to the process for its lifetime. - The sandbox is inherited by every child process, so a sandboxed shell cannot escape by spawning something else. Santa verifies that the binary it is about to allow is the same one the sandbox was set up for, so the sandboxed launch cannot be handed off to a different binary. Seatbelt decisions are never cached — every execution is re-checked. ## Creating a Sandbox rule Create the rule as usual and set **Policy** to **Seatbelt**. A seatbelt policy body is required — the rule cannot be saved without one. CEL rules can also produce a sandbox decision: if the expression can return `SEATBELT`, the rule must carry a seatbelt policy too. See the [CEL Guide](/rules/cel-guide). Workshop appends the following line to every saved policy, so the sandboxed binary is always permitted to execute itself: ```scheme (allow process-exec* (literal (param "BINARY_PATH"))) ``` It is appended only once; re-saving a rule does not duplicate it. :::warning Workshop does not validate SBPL syntax when the rule is saved. A malformed profile surfaces on the host as `sandbox_init failed: …` when the user runs `santactl sandbox`, and the command will not run. Test new profiles on a host before rolling them out broadly. ::: ## Simple mode The **Simple** tab generates a complete, deny-by-default profile from a handful of toggles. It is the recommended starting point: the generated profile already contains the boilerplate a typical CLI tool needs (system library reads, TTY access, logging, DNS, CoreFoundation shared memory) that is tedious and easy to get wrong by hand. Switching a toggle regenerates the whole profile. If the policy in the Advanced tab is not something Simple mode would have produced (because it was hand-edited), Workshop shows a warning: touching any control will overwrite it. ### Process | Control | Effect | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Allow child processes (fork/exec)** | Adds `(allow process-fork)` and `(allow process-exec)`. Required for anything that spawns subprocesses — shells, build tools, compilers. Leave it off to confine the binary to itself. | ### File reads | Control | Effect | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Allow reading `$HOME`** | Adds `(subpath (param "HOME"))` to the read list. The always-denied paths below still apply. | | **Additional read paths** | One `(subpath …)` entry per path, granting recursive read access to that directory tree. Accepts absolute paths, or `$HOME`/`$CWD`/`~` prefixes (`$HOME/Projects`, `$CWD/vendor`). | ### File writes | Control | Effect | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | **Allow writing to the working directory (`$CWD`)** | Adds `(subpath (param "CWD"))` to the write list — the directory `santactl sandbox` was invoked from. | | **Allow writing to temp directories** | Adds `/private/tmp`, `/private/var/tmp` and `/private/var/folders`. | | **Additional write paths** | One recursive `(subpath …)` write entry per path. Same `$HOME`/`$CWD` prefix handling as reads. | ### Blocked paths | Control | Effect | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Paths that cannot be read or written** | Emits `(deny file-read* file-write* …)` _after_ the allow sections. Because SBPL is last-match-wins, these win over any allow above — use them to punch holes in a broad allow. | ### Network | Control | Effect | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | **Allow HTTPS** | Outbound TCP to `*:443` plus UDP `*:443` for QUIC/HTTP3. | | **Allow localhost (inbound, outbound, bind)** | Outbound TCP to `localhost:*`, plus `network-inbound` and `network-bind` on `localhost:*`. Needed for local dev servers and stdio bridges. | | **Additional outbound TCP destinations** | A bare port (`8080`) becomes `*:8080`; a `host:port` value (`example.com:993`) is used verbatim. | Outbound UDP to port 53 (DNS) and Unix domain sockets are always allowed when any network access is granted. ### Always in the generated profile Regardless of the toggles, Simple mode emits: - `(version 1)`, `(deny default)` and `(debug deny)` — deny everything not explicitly allowed, and log each denial. - Process introspection of self and children, `sysctl-read`, and the Mach services and POSIX shared-memory names CoreFoundation, logging, DNS and the security daemons need. - Read access to `/System`, `/Library`, `/usr`, `/bin`, `/sbin`, `/opt`, `/private/etc`, timezone and mds databases, plus `/dev/null`, `/dev/zero`, `/dev/random`, `/dev/urandom` and the TTY devices. - Write access to `/dev/null`, `/dev/tty` and the process's TTY. - A `(deny … (with no-log))` block for Apple telemetry services, to keep the denial log readable. And these paths are **always denied**, even if a toggle or custom path would otherwise cover them: | Denied for | Paths | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Writes | `~/.zshrc`, `~/.zshenv`, `~/.zprofile`, `~/.bashrc`, `~/.bash_profile`, `~/.profile`, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/Library/LaunchAgents`, `~/Library/Application Support/com.apple.sharedfilelist` | | Reads | `~/.ssh`, `~/.aws/credentials`, `~/.gnupg`, `~/Library/Keychains` | ## Advanced mode The **Advanced** tab is a raw editor for the SBPL profile. Use it when Simple mode cannot express the policy you need. Anything you write here is stored verbatim (plus the appended `process-exec*` directive). ### Structure ```scheme (version 1) (deny default) ; default stance (debug deny) ; log every denial (allow file-read* (subpath "/usr") (literal "/dev/urandom")) (deny file-write* (subpath (string-append (param "HOME") "/.ssh"))) ``` Key semantics: - `(version 1)` must be the first directive. - Set the default stance with `(deny default)` or `(allow default)`. Always prefer `(deny default)`. - **Last match wins.** A later `deny` overrides an earlier `allow` for the same operation and path, which is how the "Blocked paths" section in Simple mode works. - `;` starts a comment; `;;` is the convention for section headers. - Profiles must be self-contained. `(import …)` of system profiles is not supported here. ### Operations An operation names the class of action being authorized. `*` suffixes match a family of related operations (`file-write*` covers create, unlink, mode changes and so on). | Operation | Covers | | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `default` | Everything not otherwise matched. | | `file-read*`, `file-read-data`, `file-read-metadata` | Reading file contents, and `stat`-style metadata access. | | `file-write*`, `file-write-data`, `file-write-create`, `file-write-unlink`, `file-write-mode`, `file-write-owner`, `file-write-flags` | Modifying files: contents, creation, deletion, permissions. | | `file-ioctl` | `ioctl(2)` on a file or device — needed for TTYs. | | `file-mount`, `file-umount` | Mounting and unmounting filesystems. | | `process-exec`, `process-exec*` | Executing a binary. | | `process-fork` | Creating child processes. | | `process-info*`, `process-info-pidinfo`, `process-info-pidfdinfo`, `process-info-setcontrol` | Inspecting or controlling processes. | | `signal` | Sending signals. | | `network-outbound`, `network-inbound`, `network-bind` | Connecting out, accepting in, binding a local address. | | `system-socket` | Creating sockets (including routing sockets). | | `mach-lookup`, `mach-register` | Looking up or registering Mach services (XPC). | | `ipc-posix-shm*`, `ipc-posix-shm-read*`, `ipc-posix-shm-write-data`, `ipc-posix-shm-write-create` | POSIX shared memory — CoreFoundation requires several of these. | | `ipc-posix-sem*` | POSIX semaphores. | | `sysctl-read`, `sysctl-write` | Reading and writing `sysctl` values. | | `iokit-open`, `iokit-get-properties` | Opening IOKit user clients and reading their properties. | | `user-preference-read`, `user-preference-write` | `cfprefsd` preference domains. | | `nvram*` | Reading and writing NVRAM variables. | | `device-camera`, `device-microphone` | Capture devices. | | `authorization-right-obtain` | Acquiring an Authorization Services right. | The authoritative operation set is defined by the OS and varies between macOS releases; an unrecognized operation causes `sandbox_init` to fail. Test profiles that use uncommon operations against the macOS versions you deploy to. ### Filters Filters narrow which arguments an operation applies to. Multiple filters in one rule are OR'd — the rule matches if any of them do. | Filter | Matches | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `(literal "/path")` | Exactly that path. | | `(subpath "/path")` | That directory and everything beneath it. | | `(regex #"^/dev/ttys[0-9]+$")` | Paths matching the regular expression. Note the `#"…"` literal syntax. | | `(global-name "com.apple.logd")` | A Mach service in the global namespace (used with `mach-lookup`). | | `(local-name "…")` | A Mach service in the local namespace. | | `(ipc-posix-name "…")`, `(ipc-posix-name-regex #"…")` | POSIX shared memory / semaphore names. | | `(iokit-user-client-class "IOHIDParamUserClient")` | An IOKit user client class (used with `iokit-open`). | | `(remote tcp "host:port")`, `(remote udp "*:53")` | Outbound destination. `*` is allowed for either half. | | `(local tcp "localhost:*")` | Local address, for `network-inbound` and `network-bind`. | | `(remote unix-socket)`, `(local unix-socket)` | Unix domain sockets. | | `(target self)`, `(target children)`, `(target others)` | The process a `signal` or `process-info` operation applies to. | | `(vnode-type REGULAR-FILE)` | Restrict a file operation to a vnode type (`DIRECTORY`, `SYMLINK`, `CHARACTER-DEVICE`, …). | Filters can be combined with `(require-all …)`, `(require-any …)` and `(require-not …)`: ```scheme (allow file-write* (require-all (subpath (param "CWD")) (require-not (regex #"\.git/")))) ``` ### Modifiers | Modifier | Effect | | ---------------------------- | ------------------------------------------------------------------------- | | `(with no-log)` | Suppress the log entry for this rule. Useful for noisy, expected denials. | | `(with report)` | Log the violation even when the operation is allowed. | | `(with send-signal SIGKILL)` | Kill the process on a matching denial instead of returning an error. | ```scheme (deny mach-lookup (with no-log) (global-name "com.apple.diagnosticd")) ``` ### Parameters `santactl sandbox` supplies these parameters when it applies the profile. Reference them with `(param "NAME")`, and build paths beneath them with `(string-append …)`. | Parameter | Value | | ------------- | ---------------------------------------------------------------------------------------------------------------------- | | `BINARY_PATH` | Full resolved path of the binary being sandboxed. | | `CWD` | The working directory `santactl sandbox` was invoked from. | | `HOME` | The invoking uid's home directory from the password database (so `/var/root` under `sudo`, not the inherited `$HOME`). | | `TMPDIR` | The per-user Darwin temp directory. | | `UID` | The invoking uid, as a decimal string. | ```scheme (allow file-read* (subpath (string-append (param "HOME") "/Projects"))) ``` Referencing a parameter that was not supplied makes `sandbox_init` fail. `CWD`, `HOME` and `TMPDIR` are omitted if the OS cannot resolve them. ## Using a Sandbox rule on the client The canonical command is `santactl sandbox`: ```console $ santactl sandbox [arguments...] ``` `santactl sb` is an alias for it, provided as a shorthand. The two are identical in every respect; this documentation uses the canonical `santactl sandbox` throughout. ```console $ santactl sb [arguments...] # identical to the above ``` `--` ends option parsing, if the command name would otherwise look like a flag. - The command may be a path (absolute or containing a `/`), or a bare name resolved against `PATH`. Only absolute `PATH` entries are searched. - Root is not required. The Santa daemon must be running. - Arguments after the command are passed through unchanged. - The sandbox is applied before `exec`, so it is in effect from the very first instruction of the target — and is inherited by all of its children. Common failures: | Message | Cause | | ------------------------------------- | ---------------------------------------------------------------------------- | | `command not found` | The command did not resolve to an executable regular file. | | `No matching rule` | No execution rule matches the binary. | | `Rule is not a seatbelt rule` | A rule matched, but it carries no seatbelt policy. | | `sandbox_init failed: …` | The profile is not valid SBPL, or references an undefined parameter. | | `Concurrent sandbox request rejected` | Another `santactl sandbox` request from the same process is already pending. | ### Troubleshooting a profile Profiles generated by Simple mode include `(debug deny)`, so every denial is logged. Watch them live on the host: ```console $ log stream --style compact --predicate '((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting")' ``` Each entry names the operation and the argument that was denied, which maps directly onto the operation/filter pair to add to the profile. Iterate in the Advanced tab until the denial log is clean, then roll the rule out. ## Best practices - **Start narrow.** Keep `(deny default)` and grant only what the tool actually needs; a profile that starts from `(allow default)` protects nothing. - **Prefer Simple mode.** The generated baseline handles the OS plumbing that is easy to get wrong. Drop to Advanced only for the parts Simple mode cannot express. - **Scope by code signing identity.** As with any execution rule, a CDHash, Signing ID or Team ID rule is far harder to sidestep than a binary path. - **Test on one host first.** A broken profile does not fall back to unsandboxed execution — the binary simply will not run. - **Watch the rollout.** Hosts on Santa older than 2026.6 will silently not receive the rule, so the binary continues to be evaluated by the rest of your policy. --- ## Time Based Rules # Time Based Rules A time based rule is an [execution rule](/rules/execution-rules) 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. :::info 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 ```cel 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](#cacheability). ## Window Forms `policy_for_range()` has four forms, one per window shape. The policy arguments are always last. | Form | Window | Typical use | | ------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | | `policy_for_range(list, start, end, policy, out_of_range_policy)` | Weekly `HH:MM` window on each host's own clock | Working hours, on-call hours, hours a lab machine may be used | | `policy_for_range(list, start, end, tz, policy, out_of_range_policy)` | The same window read in the time zone you name | One 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 timestamps | A 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 launch | Timed access, where each launch is quit some time later | ### Weekly Window ```cel 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 ```cel 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 ```cel 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 ```cel 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 | Value | Meaning | | ------------ | -------------------------------------------------------------------------- | | `0` to `6` | Sunday 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: | Value | Resolves 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](/rules/cel-guide#return-values), 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 range | Out of range | Effect | | ----------- | ------------------------------------------- | -------------------------------------------------------------------------- | | `ALLOWLIST` | `BLOCKLIST` | Available during the window, blocked outside it | | `ALLOWLIST` | `require_touchid_with_cooldown_minutes(60)` | Available during the window, needs a fingerprint outside it | | `ALLOWLIST` | `AUDIT` | Always 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](/rules/sandbox-rules). ::: ## Quitting Processes When the Window Closes {#kill-on-expiry} 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: ```cel 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: | Window | Warning | | --------------- | ---------------- | | 8 hours | 48 minutes ahead | | 1 hour | 6 minutes ahead | | 30 minutes | 5 minutes ahead | | Under 5 minutes | At launch | The notification reads `"" 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 ```cel 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. ```cel 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. ```cel policy_for_range(weekdays(), "09:00", "17:00", ALLOWLIST, AUDIT) ``` ### One Maintenance Window for the Whole Fleet ```cel 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. ```cel policy_for_range([0, 6], "00:00", "00:00", BLOCKLIST, ALLOWLIST) ``` ### A Dated Exception ```cel 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. ```cel 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: ```cel 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. ```cel 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. ```cel "--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: ```cel 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. | Expression | Why 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 own | The 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 slot | Only 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 == 0` | The call returns a decision, not a boolean. Use a ternary | | One `policy_for_range()` inside another's arguments | CEL 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](/rules/cel-guide#caching) in the CEL Guide. ## Troubleshooting The daemon logs every step of a pending quit: ```sh /usr/bin/log stream --level debug --predicate 'sender == "com.northpolesec.santa.daemon"' ``` | Log line | Meaning | | ------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `Recorded timed rule kill for : quitting at , warning at ` | The first execution under the rule was recorded | | `Recorded execution under timed rule kill for (pid …)` | A later execution joined the same deadline | | `Sending timed rule kill banner for (), quitting at ` | The warning notification went to the GUI | | `Timed rule kill firing for : N recorded process(es)` | The deadline arrived and N processes are being quit | | `Timed rule kill for deferred: its window is open again until , nothing quit` | The window was standing open at the deadline | | `Timed rule kill for cancelled: the rule is gone` | The rule was deleted before the deadline | | `Timed rule kill for cancelled: the rule changed (rule id X -> Y)` | The rule was edited before the deadline | | `Ignoring timed rule kill for : no server-assigned rule id` | The 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. --- ## Filter Expressions # Telemetry Filter Expressions Telemetry filter expressions are CEL expressions evaluated by Santa on the client before events are uploaded. They give you fine-grained control over which events are sent to your telemetry bucket — for example, to drop noisy event types entirely, exclude events from a specific binary, or redact sensitive values like tokens out of an event before it leaves the host. Filter expressions are configured per-tag in Sync Settings and apply to every event Santa produces. They are evaluated locally on the host, so events that are filtered out never reach Workshop or your cloud storage bucket. Filter expressions can also be set directly in the Santa configuration profile via the [`TelemetryFilterExpressions`](https://northpole.dev/configuration/keys/#TelemetryFilterExpressions) key — useful when you want to apply a baseline filter through MDM rather than through Workshop sync. For a full reference of the CEL environment Santa exposes — including all event types, fields, and built-in functions — see [Santa's telemetry documentation](https://northpole.dev/features/telemetry/). ## Configuration Telemetry filter expressions are managed on the **Telemetry** tab of the Sync Settings editor. Each tag may define any number of expressions; the effective list for a host is determined by the same tag-precedence rules used for all other sync settings. Hosts must be enrolled in a tag whose telemetry configuration has telemetry **enabled** for expressions to take effect. See the [Telemetry overview](/telemetry/) for how telemetry collection is set up. ## Expression Semantics Each filter expression is evaluated once per event. The expression's boolean result determines the event's fate: `TRUE` means drop, `FALSE` means keep. If any expression returns `TRUE`, processing stops early and the event is immediately dropped - in other words, all expressions must return false for the event to be uploaded. Expressions also have access to two Santa functions for scrubbing sensitive values out of an event in-place before it is uploaded: - `hash(value, regex)` — replaces the regex's captured group with a hash of the matched substring. The original value never leaves the host, but the hashed value can still be used to correlate the same secret across events. - `redact(value, regex)` — replaces the regex's captured group with a fixed redaction marker. Use this when you want to scrub the value entirely with no correlation across events. Both functions return `false`, so they compose naturally inside boolean expressions like `exists()` — the filter can match on a sensitive value and scrub it in a single pass. ## The `event` Variable The top-level variable available to a filter expression is `event`. It is a union of all Santa event types; exactly one sub-field is populated per event. The sub-field name is the event type's lowercase `snake_case` identifier — matching the headings on the [Schema](/telemetry/schema/macos) page. Field names _within_ an event are PascalCase; only the event-type key itself is `snake_case`. Expressions are case-sensitive. `event.file_access` is correct; `event.FileAccess` and `event.fileaccess` both silently never match, because `has()` simply returns false for an unknown key and short-circuits the whole expression. | Sub-field | Event type | | ------------------------------- | ----------------------- | | `event.execution` | Process execution | | `event.fork` | Process fork | | `event.exit` | Process exit | | `event.close` | File close | | `event.rename` | File rename | | `event.link` | Hard link creation | | `event.unlink` | File deletion | | `event.clone` | File clone | | `event.exchangedata` | File data exchange | | `event.copyfile` | File copy | | `event.file_access` | File access policy hit | | `event.authentication` | Authentication attempt | | `event.login_logout` | Console login/logout | | `event.login_window_session` | GUI session events | | `event.open_ssh` | SSH login/logout | | `event.allowlist` | Allowlist additions | | `event.bundle` | Bundle hash event | | `event.gatekeeper_override` | Gatekeeper bypass | | `event.tcc_modification` | TCC modification | | `event.xprotect` | XProtect detection | | `event.screen_sharing` | Screen sharing event | | `event.disk` | Disk mount/unmount | | `event.launch_item` | Launch item event | | `event.proc_suspend_resume` | Process suspend/resume | | `event.codesigning_invalidated` | Codesigning invalidate | | `event.network_activity` | Network connection flow | Use the `has()` macro to test which sub-field is populated. Once you've gated on the event type, you can navigate into nested fields using the same names that appear on the [Schema](/telemetry/schema/macos) page. ```cel has(event.execution) && event.execution.Target.Executable.Path == '/usr/bin/yes' ``` ## CEL Basics Telemetry filter expressions use the same CEL language as [CEL rules](/rules/execution-rules#cel-policy-rules) and CEL fallback rules. The following are commonly useful: - **Logical**: `&&`, `||`, `!` - **Comparison**: `==`, `!=`, `<`, `>`, `<=`, `>=` - **String functions**: `startsWith()`, `endsWith()`, `contains()`, `matches()`, `size()` - **List macros**: `exists()`, `all()`, `filter()`, `map()`, `size()` - **Optional field access**: `has()` See [celbyexample.com](https://celbyexample.com) for a comprehensive CEL reference. ## Examples ### Keep only execution events ```cel ! has(event.execution) ``` :::note If you want to disable whole event types entirely, prefer the Santa [`Telemetry`](https://northpole.dev/configuration/keys/#Telemetry) configuration key — it stops Santa from producing those events at all, which is cheaper than producing each event and then dropping it with a filter expression. ::: ### Drop executions from a noisy path ```cel has(event.execution) && event.execution.Target.Executable.Path.startsWith('/opt/homebrew/') ``` ### Redact a token-shaped environment variable ```cel has(event.execution) && event.execution.Envs.exists(e, e.startsWith("GITHUB_TOKEN") && hash(e, "GITHUB_TOKEN=(.*)")) ``` This expression matches execution events that carry a `GITHUB_TOKEN` environment variable and uses `hash()` with the regex `GITHUB_TOKEN=(.*)` to replace the captured value with a hash before the event is uploaded. The original token never leaves the host, but the hashed value can still be used to correlate the same token across events. ### Redact a token entirely ```cel has(event.execution) && event.execution.Envs.exists(e, e.startsWith("AWS_SECRET_ACCESS_KEY") && redact(e, "AWS_SECRET_ACCESS_KEY=(.*)")) ``` Identical in shape to the `hash()` example, but uses `redact()` to replace the captured value with a fixed marker — appropriate when you don't need to correlate the same secret across events. ### Drop a specific file-access policy from upload ```cel has(event.file_access) && event.file_access.PolicyName == 'noisy-policy' ``` ### Keep authentication events for failures only ```cel has(event.authentication) && event.authentication.Success ``` ## Validation and Rollout Workshop stores expressions as plain strings and delivers them to hosts on the next sync. Validation is performed by Santa when it parses the configuration — an invalid expression is logged on the host and ignored, so test changes on a small tag before rolling them out widely. A safe rollout pattern is: 1. Apply the new expression to a single host or test tag. 2. Confirm in your telemetry bucket that the expected events are kept, dropped, or redacted. 3. Promote the expression to a broader tag once you're satisfied. ## See Also - [Telemetry](/telemetry/) — how to enable telemetry collection - [Schema](/telemetry/schema/macos) — the field names referenced by `event.*` - [CEL Execution Rules](/rules/execution-rules#cel-policy-rules) — CEL used for policy decisions - [Santa telemetry documentation](https://northpole.dev/features/telemetry/) — authoritative reference for the CEL environment exposed by Santa --- ## Schema # Telemetry Schema Workshop documents the telemetry schema per platform, because Santa describes each operating system in its own terms. - [macOS Telemetry Schema](/telemetry/schema/macos) - every event type Santa collects on macOS, and the fields on each. Columns drift across Santa versions, so `DESCRIBE` on your own data is always the authoritative list. --- ## macOS Telemetry Schema {/* Code generated from the telemetry schema by github.com/northpolesec/sleigh/cmd/schemadoc. DO NOT EDIT. */} # macOS Telemetry Schema This page documents the complete schema for all telemetry event types collected by Workshop from Santa agents on macOS. Each event type below is also the table-name prefix in SQL queries: Execution fields live in `execution_2025`, `execution_202501`, or `execution_20250125` (see [table naming](/telemetry#table-naming-convention)). Columns drift across Santa versions, so `DESCRIBE execution_20250125` on your own data is the authoritative list. ## Contents **Event tables** - Process Events: [execution](#execution), [fork](#fork), [exit](#exit), [proc_suspend_resume](#proc_suspend_resume), [codesigning_invalidated](#codesigning_invalidated) - File System Events: [close](#close), [file_access](#file_access), [rename](#rename), [link](#link), [unlink](#unlink), [clone](#clone), [exchangedata](#exchangedata), [copyfile](#copyfile) - Authentication & Session Events: [authentication](#authentication), [login_logout](#login_logout), [login_window_session](#login_window_session), [open_ssh](#open_ssh) - Security Events: [allowlist](#allowlist), [bundle](#bundle), [gatekeeper_override](#gatekeeper_override), [tcc_modification](#tcc_modification), [xprotect](#xprotect), [screen_sharing](#screen_sharing) - System Events: [disk](#disk), [launch_item](#launch_item) - Network Events: [network_activity](#network_activity) - Inventory Events: [packages](#packages) **Common types** [ProcessID](#processid), [UserInfo](#userinfo), [GroupInfo](#groupinfo), [Hash](#hash), [Stat](#stat), [FileInfoLight](#fileinfolight), [FileInfo](#fileinfo), [CodeSignature](#codesignature), [CertificateInfo](#certificateinfo), [Entitlement](#entitlement), [EntitlementInfo](#entitlementinfo), [ProcessInfoLight](#processinfolight), [ProcessInfo](#processinfo), [FileDescriptor](#filedescriptor), [SocketAddress](#socketaddress), [GraphicalSession](#graphicalsession) ## Base Fields | Field | Type | Description | | --------------- | --------- | -------------------------------------------------------------------- | | EventID | text | Unique identifier for the event | | MachineID | text | The unique machine ID (host UUID) | | Hostname | text | The hostname of the machine at the time of the event | | BootSessionUUID | text | Unique identifier for the boot session | | EventTime | timestamp | When the event occurred | | ProcessedTime | timestamp | When Workshop processed the event | | OperatingSystem | text | The platform the telemetry came from, always `macos` in these tables | ## Process Events ### execution Process execution events. | Field | Type | Description | | -------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Parent process | | Target | [ProcessInfo](#processinfo) | Executed process | | Script | [FileInfo](#fileinfo) | The script that was being executed, if applicable | | WorkingDirectory | [FileInfo](#fileinfo) | The working directory | | Args | text array | Command-line arguments | | Envs | text array | Environment variables | | FDs | [FileDescriptor](#filedescriptor) array | The open file descriptors at time of execution | | FDListTruncated | boolean | Whether the list in `FDs` is truncated | | Decision | text | The decision that was made by Santa, e.g. `DECISION_ALLOW` | | Reason | text | The reason that Santa made the decision it did, e.g. `REASON_CERT` | | Mode | text | Santa's client mode at the time of the event, e.g. `MODE_MONITOR` | | CertificateInfo | [CertificateInfo](#certificateinfo) | The common name and hash of the leaf certificate that signed this binary, if applicable | | Explain | text | Possible additional context related to this execution | | QuarantineURL | text | The URL the binary was downloaded from, if known | | OriginalPath | text | The original on-disk path of the target executable, applies when binaries are translocated (https://developer.apple.com/forums/thread/724969) | | EntitlementInfo | [EntitlementInfo](#entitlementinfo) | The entitlements attached to this binary | | RuleID | number | The ID of the rule that produced the decision, if applicable | | StaticRule | boolean | Whether the decision came from a static (configuration profile) rule | | AuditReturn | boolean | Whether the decision was made in audit (non-blocking) mode | | TemporaryMonitorMode | boolean | Whether the reported `Mode` came from an active Temporary Monitor Mode session rather than the configured client mode | ### fork Process fork events. | Field | Type | Description | | ---------- | ------------------------------------- | --------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Parent process | | Child | [ProcessInfoLight](#processinfolight) | Child processes | ### exit Process termination events. | Field | Type | Description | | ---------- | ------------------------------------- | ------------------------------------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Exiting process | | ExitCode | number | Exit code of the process (set when process exits normally) | | Signaled | number | Signal number that terminated the process (set when terminated by signal) | | Stopped | number | Signal number that stopped the process (set when stopped by signal) | ### proc_suspend_resume Process suspend and resume events. | Field | Type | Description | | ---------- | ------------------------------------- | ---------------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | The process that initiated the suspend/resume action | | Target | [ProcessInfo](#processinfo) | The process being suspended or resumed | | Type | text | The type of action, e.g. `TYPE_SUSPEND` | ### codesigning_invalidated Code signature invalidation events. | Field | Type | Description | | ---------- | ------------------------------------- | ---------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process with invalidated signature | ## File System Events ### close File close events. | Field | Type | Description | | ---------- | ------------------------------------- | ---------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | The process closing the file | | Target | [FileInfo](#fileinfo) | The file being closed | | Modified | boolean | Whether file was modified | ### file_access File access monitoring events. | Field | Type | Description | | -------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------- | | Instigator | [ProcessInfo](#processinfo) | The process accessing the file | | Target | [FileInfo](#fileinfo) | The file being accessed | | PolicyVersion | text | The version of the file-access policy | | PolicyName | text | The name of the file-access policy | | AccessType | text | The type of event that attempted access, e.g. `ACCESS_TYPE_UNLINK` | | PolicyDecision | text | The decision that was made, e.g. `POLICY_DECISION_ALLOWED_AUDIT_ONLY` | | OperationID | text | Unique operation identifier, used to link a single operation when a single operation violates multiple policies | | RuleID | number | The ID of the file-access rule that produced the decision, if applicable | ### rename File rename/move events. | Field | Type | Description | | ------------- | ------------------------------------- | --------------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | The process that is renaming the file | | Source | [FileInfo](#fileinfo) | The source file | | Target | text | The destination path | | TargetExisted | boolean | Whether or not the destination path already existed | ### link Hard link creation events. | Field | Type | Description | | ---------- | ------------------------------------- | --------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | The process making the link | | Source | [FileInfo](#fileinfo) | The source file | | Target | text | Link path | ### unlink File deletion events. | Field | Type | Description | | ---------- | ------------------------------------- | ------------------------------ | | Instigator | [ProcessInfoLight](#processinfolight) | The process unlinking the file | | Target | [FileInfo](#fileinfo) | The deleted file info | ### clone File clone (copy-on-write) events. | Field | Type | Description | | ---------- | ------------------------------------- | ---------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process performing the clone | | Source | [FileInfo](#fileinfo) | Source file | | Target | text | Clone destination | ### exchangedata Atomic data exchange between files events. | Field | Type | Description | | ---------- | ------------------------------------- | ------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process performing the exchange | | File1 | [FileInfo](#fileinfo) | First file | | File2 | [FileInfo](#fileinfo) | Second file | ### copyfile File copy events. | Field | Type | Description | | ------------- | ------------------------------------- | --------------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | The process performing the copy | | Source | [FileInfo](#fileinfo) | The source file | | Target | text | The destination path | | TargetExisted | boolean | Whether or not the destination path already existed | | Mode | number | The mode of the copied file | | Flags | number | The copyfile flags for the operation | ## Authentication & Session Events ### authentication Authentication attempts. Exactly one of the subtype fields is populated, depending on the authentication method. | Field | Type | Description | | ---------- | --------------------- | ----------------------------------------------------------- | | Success | boolean | Authentication result | | OD | OpenDirectory subtype | OpenDirectory authentication data, when the attempt used it | | TouchID | TouchID subtype | Touch ID authentication data, when the attempt used it | | Token | Token subtype | Token authentication data, when the attempt used it | | AutoUnlock | AutoUnlock subtype | Auto unlock authentication data, when the attempt used it | **OpenDirectory subtype:** | Field | Type | Description | | -------------- | ------------------------------------- | ----------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process performing the authentication | | TriggerProcess | [ProcessInfoLight](#processinfolight) | Process that triggered the authentication | | TriggerID | [ProcessID](#processid) | Process ID of the trigger process | | RecordType | text | OpenDirectory record type | | RecordName | text | OpenDirectory record name | | NodeName | text | OpenDirectory node name | | DBPath | text | Path to the OpenDirectory database | **TouchID subtype:** | Field | Type | Description | | -------------- | ------------------------------------- | ----------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process performing the authentication | | TriggerProcess | [ProcessInfoLight](#processinfolight) | Process that triggered the authentication | | TriggerID | [ProcessID](#processid) | Process ID of the trigger process | | Mode | text | Touch ID mode | | User | [UserInfo](#userinfo) | User being authenticated | **Token subtype:** | Field | Type | Description | | ----------------- | ------------------------------------- | ----------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process performing the authentication | | TriggerProcess | [ProcessInfoLight](#processinfolight) | Process that triggered the authentication | | TriggerID | [ProcessID](#processid) | Process ID of the trigger process | | PubkeyHash | text | Hash of the public key | | TokenID | text | Token identifier | | KerberosPrincipal | text | Kerberos principal | **AutoUnlock subtype:** | Field | Type | Description | | ---------- | ------------------------------------- | ------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process performing the authentication | | UserInfo | [UserInfo](#userinfo) | User being authenticated | | Type | text | Auto unlock type | ### login_logout Console login/logout events. This event type has subtypes for login and logout. **Login subtype:** | Field | Type | Description | | -------------- | ------------------------------------- | ----------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process handling the login | | Success | boolean | Whether login was successful | | FailureMessage | text | Error message if login failed | | User | [UserInfo](#userinfo) | User logging in | **Logout subtype:** | Field | Type | Description | | ---------- | ------------------------------------- | --------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process handling the logout | | User | [UserInfo](#userinfo) | User logging out | ### login_window_session GUI session events. This event type has subtypes for different session actions. **Login subtype:** | Field | Type | Description | | ---------------- | ------------------------------------- | ---------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process handling the session login | | User | [UserInfo](#userinfo) | User logging in | | GraphicalSession | [GraphicalSession](#graphicalsession) | Graphical session information | **Logout subtype:** | Field | Type | Description | | ---------------- | ------------------------------------- | ----------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process handling the session logout | | User | [UserInfo](#userinfo) | User logging out | | GraphicalSession | [GraphicalSession](#graphicalsession) | Graphical session information | **Lock subtype:** | Field | Type | Description | | ---------------- | ------------------------------------- | ---------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process handling the session lock | | User | [UserInfo](#userinfo) | User whose session is being locked | | GraphicalSession | [GraphicalSession](#graphicalsession) | Graphical session information | **Unlock subtype:** | Field | Type | Description | | ---------------- | ------------------------------------- | ------------------------------------ | | Instigator | [ProcessInfoLight](#processinfolight) | Process handling the session unlock | | User | [UserInfo](#userinfo) | User whose session is being unlocked | | GraphicalSession | [GraphicalSession](#graphicalsession) | Graphical session information | ### open_ssh SSH authentication events. This event type has subtypes for SSH login and logout. **Login subtype:** | Field | Type | Description | | ---------- | ------------------------------------- | ------------------------------------ | | Instigator | [ProcessInfoLight](#processinfolight) | SSH daemon process | | Result | text | Authentication result | | Source | [SocketAddress](#socketaddress) | Source address of the SSH connection | | User | [UserInfo](#userinfo) | User attempting to log in | **Logout subtype:** | Field | Type | Description | | ---------- | ------------------------------------- | ------------------------------------ | | Instigator | [ProcessInfoLight](#processinfolight) | SSH daemon process | | Source | [SocketAddress](#socketaddress) | Source address of the SSH connection | | User | [UserInfo](#userinfo) | User logging out | ## Security Events ### allowlist Binary allowlist addition events. | Field | Type | Description | | ---------- | ------------------------------------- | ---------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process that added the binary to the allowlist | | Target | [FileInfo](#fileinfo) | Binary being added to the allowlist | ### bundle Bundle hash events. | Field | Type | Description | | ---------- | ------------- | ---------------------------------- | | FileHash | [Hash](#hash) | Hash of the individual file | | BundleHash | [Hash](#hash) | Hash of the entire bundle | | BundleName | text | Name of the bundle | | BundleID | text | Bundle identifier | | BundlePath | text | Path to the bundle | | Path | text | Path to the file within the bundle | ### gatekeeper_override Gatekeeper bypass events. | Field | Type | Description | | ------------- | ------------------------------------- | ----------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process that bypassed Gatekeeper | | Target | [FileInfo](#fileinfo) | File that was allowed to run despite Gatekeeper | | CodeSignature | [CodeSignature](#codesignature) | Code signing information | ### tcc_modification TCC (Transparency, Consent, and Control) database modification events. | Field | Type | Description | | ------------------- | ------------------------------------- | ----------------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process modifying TCC database | | Service | text | TCC service being modified (e.g., camera, microphone) | | Identity | text | Identity being granted/revoked access | | IdentityType | text | Type of identity (bundle ID, path, etc.) | | EventType | text | Type of modification event | | AuthorizationRight | text | Authorization right being modified | | AuthorizationReason | text | Reason for the authorization change | | TriggerProcess | [ProcessInfoLight](#processinfolight) | Process that triggered the modification | | TriggerID | [ProcessID](#processid) | Process ID of the trigger process | | ResponsibleProcess | [ProcessInfoLight](#processinfolight) | Process responsible for the modification | | ResponsibleID | [ProcessID](#processid) | Process ID of the responsible process | ### xprotect XProtect malware detection and remediation events. This event type has subtypes for detection and remediation. **Detected subtype:** | Field | Type | Description | | ------------------ | ------------------------------------- | ----------------------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | XProtect process that detected the malware | | SignatureVersion | text | Version of the XProtect signature that detected the malware | | MalwareIdentifier | text | Identifier for the detected malware | | IncidentIdentifier | text | Unique identifier for this detection incident | | DetectedPath | text | Path where malware was detected | **Remediated subtype:** | Field | Type | Description | | ------------------- | ------------------------------------- | --------------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | XProtect process that remediated the malware | | SignatureVersion | text | Version of the XProtect signature | | MalwareIdentifier | text | Identifier for the remediated malware | | IncidentIdentifier | text | Unique identifier for this remediation incident | | ActionType | text | Type of remediation action taken | | Success | boolean | Whether remediation was successful | | ResultDescription | text | Description of the remediation result | | RemediatedPath | text | Path that was remediated | | RemediatedProcessID | [ProcessID](#processid) | Process ID of the remediated process, if applicable | ### screen_sharing Screen sharing connection events. This event type has subtypes for attach and detach. **Attach subtype:** | Field | Type | Description | | ------------------ | ------------------------------------- | ---------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process handling the screen sharing connection | | Success | boolean | Whether the connection was successful | | Source | [SocketAddress](#socketaddress) | Source address of the connection | | Viewer | text | Identifier of the viewer | | AuthenticationType | text | Type of authentication used | | AuthenticationUser | [UserInfo](#userinfo) | User that authenticated | | SessionUser | [UserInfo](#userinfo) | User whose session is being shared | | ExistingSession | boolean | Whether connecting to an existing session | | GraphicalSession | [GraphicalSession](#graphicalsession) | Graphical session information | **Detach subtype:** | Field | Type | Description | | ---------------- | ------------------------------------- | ---------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process handling the disconnection | | Source | [SocketAddress](#socketaddress) | Source address of the connection | | Viewer | text | Identifier of the viewer | | GraphicalSession | [GraphicalSession](#graphicalsession) | Graphical session information | ## System Events ### disk Disk mount/unmount events. | Field | Type | Description | | ---------- | --------- | ---------------------------------------------------------------- | | Action | text | Whether the disk appeared or disappeared, e.g. `ACTION_APPEARED` | | Mount | text | The path the disk is mounted at | | Volume | text | The name of the volume that was attached | | BSDName | text | The BSD name of the disk (e.g. `/dev/disk2s1`) | | FS | text | The filesystem on the disk | | Model | text | Device vendor and model information | | Serial | text | The serial number of the attached disk | | Bus | text | The bus path/protocol of the attached disk | | DMGPath | text | The path of the backing disk image, if the disk is a disk image | | Appearance | timestamp | The time the device appeared/disappeared | | MountFrom | text | The path mounted from | | Encrypted | boolean | Whether the disk is encrypted | ### launch_item Launch item registration/removal events. | Field | Type | Description | | ----------------- | ------------------------------------- | ---------------------------------------------------------------------------------------- | | Instigator | [ProcessInfoLight](#processinfolight) | Process handling the launch item registration | | Action | text | Whether a launch item was added or removed, e.g. `ACTION_ADD` | | TriggerProcess | [ProcessInfoLight](#processinfolight) | The process that triggered registration (one of TriggerProcess or TriggerID will be set) | | TriggerID | [ProcessID](#processid) | Process ID that triggered registration (one of TriggerProcess or TriggerID will be set) | | RegistrantProcess | [ProcessInfoLight](#processinfolight) | The app that registered the launch item (may be set) | | RegistrantID | [ProcessID](#processid) | Process ID of the app that registered the launch item (may be set) | | ItemType | text | The kind of item that was registered, e.g. `ITEM_TYPE_AGENT`, `ITEM_TYPE_DAEMON` | | Legacy | boolean | Whether or not the launch item is a legacy plist | | Managed | boolean | Whether or not the launch item is managed by MDM | | ItemUser | [UserInfo](#userinfo) | User information related to the launch item | | ItemPath | text | The location of the launch item | | AppPath | text | The path of the app the launch item is attributed to | | ExecutablePath | text | If available, the associated executable path from the launch item plist | ## Network Events ### network_activity Network connection activity events. Each row represents a single network connection associated with a process. | Field | Type | Description | | -------------- | --------------------------- | -------------------------------------------------------------- | | Process | [ProcessInfo](#processinfo) | The process that initiated or received the flow | | ID | text | Unique identifier for this flow | | Hash | text | Hash of the flow | | RemoteAddress | text | Remote IP address | | RemotePort | number | Remote port number | | RemoteHostname | text | Remote hostname, if known | | LocalAddress | text | Local IP address | | LocalPort | number | Local port number | | ProtocolRaw | number | IANA protocol number | | Protocol | text | Protocol name (e.g., `TCP`, `UDP`) | | SocketFamily | text | Socket family (e.g., `SOCKET_FAMILY_INET`) | | Direction | text | Flow direction (e.g., `DIRECTION_OUTBOUND`) | | Decision | text | The decision that was made for the flow | | DecisionTier | text | The tier that produced the decision | | RuleID | number | The ID of the rule that produced the decision, if applicable | | RuleName | text | The name of the rule that produced the decision, if applicable | | BytesInbound | number | Number of bytes received | | BytesOutbound | number | Number of bytes sent | | StartTime | timestamp | When the flow started | | CloseTime | timestamp | When the flow closed | ## Inventory Events Unlike every other table on this page, inventory tables are not populated by the continuous telemetry stream. They are produced on demand by the Package Inventory command (**Hosts → Commands → Run command**), which asks each targeted host to run a read-only scan and upload the results into its normal telemetry prefix. A host that has never been scanned has no rows. Two consequences worth knowing when querying: - **`BootSessionUUID` is always empty.** An on-demand scan isn't tied to a boot session. - **`EventTime` is the scan time**, not the time a package was installed — the scan observes current state and has no visibility into when it came to be. ### packages One row per package discovered on a host. Every ecosystem shares this single table, distinguished by `Ecosystem`, so a fleet-wide query needs no unions. | Field | Type | Description | | ------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | EventID | text | Unique identifier for the inventory record | | MachineID | text | The unique machine ID (host UUID) | | Hostname | text | The hostname of the scanned host | | BootSessionUUID | text | Always empty because an inventory scan is not tied to a boot session | | EventTime | timestamp | When the inventory scan observed the package | | ProcessedTime | timestamp | When Workshop processed the inventory record | | OperatingSystem | text | The platform the scanned host is running | | RecordType | text | Always `package` in this table | | RunID | text | Identifier shared by every row from one scan; use it to isolate a single scan's results | | Profile | text | Scan profile that produced the row: `baseline`, `project`, or `deep` | | Ecosystem | text | `npm`, `pypi`, `go`, `rubygems`, `packagist`, `mcp`, `editor-extension`, `browser-extension`, `homebrew`, `agent-skill`, or `nix` | | PackageName | text | Package name as written in the manifest or lock file | | NormalizedName | text | Ecosystem-normalized name — join on this rather than `PackageName` | | Version | text | Installed version. Empty when no exact version could be determined | | ProjectPath | text | Root of the project the package belongs to, for project-scoped finds | | RootKind | text | Why the containing directory was walked: `global_package_root`, `user_package_root`, `project_root`, `editor_extension_root`, `browser_extension_root`, `mcp_config_root`, `homebrew_root`, `agent_skill_root`, `deep_home_root`, or `unknown` | | InstallScope | text | Ecosystem-specific dependency scope (e.g. `prod`/`dev` for npm and pnpm, `indirect` for Go modules) | | PackageManager | text | Manager that installed the package (e.g. `npm`, `pnpm`, `homebrew`, `firefox-extension`) | | SourceType | text | Kind of evidence the row came from (e.g. `package.json`, `browser-extension`) | | SourceFile | text | Path to the manifest, lock file, or metadata file the row was read from | | DirectDependency | boolean | Whether the package is directly depended on rather than transitive. Null when the ecosystem can't distinguish | | HasLifecycleScripts | boolean | Whether the package declares install-time lifecycle scripts — these execute on install, so they are a supply-chain execution surface | | LifecycleScripts | text array | Names of the declared lifecycle scripts | | Confidence | text | How certain the identification is: `high`, or `medium` when the name or version had to be inferred | | RequestedSpec | text | For MCP entries configured by spec, the requested selector (e.g. `@playwright/mcp@latest`) with `PackageName` normalized to the bare name | | LocalAlias | text | Local name assigned in a config file, where that differs from the package it resolves to. Set only for `mcp` (the key under `mcpServers`) and `agent-skill` (the local skill name) | ## Common Nested Types The following types are used throughout the telemetry schema to represent shared data structures. ### ProcessID Unique identifier for a process during OS runtime. | Field | Type | Description | | ---------- | ------ | ------------------------------------------------ | | PID | number | Process ID | | PIDVersion | number | Process ID version for tracking across PID reuse | ### UserInfo User identification information. | Field | Type | Description | | ----- | ------ | ----------- | | UID | number | User ID | | Name | text | User name | ### GroupInfo Group identification information. | Field | Type | Description | | ----- | ------ | ----------- | | GID | number | Group ID | | Name | text | Group name | ### Hash Cryptographic hash information. | Field | Type | Description | | ----- | ---- | ----------------------------------------- | | Type | text | Hash algorithm (e.g., `HASH_ALGO_SHA256`) | | Hash | text | Hash value | ### Stat File metadata from stat(2) syscall. | Field | Type | Description | | ---------------- | ----------------------- | ----------------------------- | | Dev | number | Device ID | | Mode | number | File mode and permissions | | Nlink | number | Number of hard links | | Ino | number | Inode number | | User | [UserInfo](#userinfo) | File owner | | Group | [GroupInfo](#groupinfo) | File group | | Rdev | number | Device ID for special files | | AccessTime | timestamp | Last access time | | ModificationTime | timestamp | Last modification time | | ChangeTime | timestamp | Last status change time | | BirthTime | timestamp | Creation time | | Size | number | File size in bytes | | Blocks | number | Number of blocks allocated | | Blksize | number | Block size for filesystem I/O | | Flags | number | User defined flags | | Gen | number | File generation number | ### FileInfoLight Basic file information with path only. | Field | Type | Description | | --------- | ------- | ------------------------------ | | Path | text | File path | | Truncated | boolean | Whether the path was truncated | ### FileInfo Comprehensive file information. | Field | Type | Description | | --------- | ------------- | ------------------------------ | | Path | text | File path | | Truncated | boolean | Whether the path was truncated | | Stat | [Stat](#stat) | File metadata | | Hash | [Hash](#hash) | File content hash | ### CodeSignature Code signing information. | Field | Type | Description | | ----------------- | --------- | ----------------------------- | | CDHash | text | Code directory hash (hex) | | SigningID | text | Signing identifier | | TeamID | text | Team identifier | | SigningTime | timestamp | Signing timestamp | | SecureSigningTime | timestamp | Secure timestamp from signing | ### CertificateInfo Certificate information for signed code. | Field | Type | Description | | ---------- | ------------- | ----------------------- | | Hash | [Hash](#hash) | Certificate hash | | CommonName | text | Certificate common name | ### Entitlement Individual entitlement key-value pair. | Field | Type | Description | | ----- | ---- | ----------------- | | Key | text | Entitlement key | | Value | text | Entitlement value | ### EntitlementInfo Collection of process entitlements. | Field | Type | Description | | -------------------- | --------------------------------- | ------------------------------------------ | | EntitlementsFiltered | boolean | Whether the entitlements list was filtered | | Entitlements | [Entitlement](#entitlement) array | List of entitlements | ### ProcessInfoLight Lightweight process information. | Field | Type | Description | | ----------------- | ------------------------------- | ---------------------------------------- | | ID | [ProcessID](#processid) | Process identifier | | ParentID | [ProcessID](#processid) | Parent process identifier | | OriginalParentPID | number | Original parent PID (before reparenting) | | GroupID | number | Process group ID | | SessionID | number | Session ID | | EffectiveUser | [UserInfo](#userinfo) | Effective user | | EffectiveGroup | [GroupInfo](#groupinfo) | Effective group | | RealUser | [UserInfo](#userinfo) | Real user | | RealGroup | [GroupInfo](#groupinfo) | Real group | | Executable | [FileInfoLight](#fileinfolight) | Executable file path | ### ProcessInfo Full process information. | Field | Type | Description | | ----------------- | ------------------------------- | ------------------------------------------- | | ID | [ProcessID](#processid) | Process identifier | | ParentID | [ProcessID](#processid) | Parent process identifier | | ResponsibleID | [ProcessID](#processid) | Responsible process identifier | | OriginalParentPID | number | Original parent PID (before reparenting) | | GroupID | number | Process group ID | | SessionID | number | Session ID | | EffectiveUser | [UserInfo](#userinfo) | Effective user | | EffectiveGroup | [GroupInfo](#groupinfo) | Effective group | | RealUser | [UserInfo](#userinfo) | Real user | | RealGroup | [GroupInfo](#groupinfo) | Real group | | IsPlatformBinary | boolean | Whether this is a platform binary | | IsESClient | boolean | Whether this is an Endpoint Security client | | CodeSignature | [CodeSignature](#codesignature) | Code signing information | | CSFlags | number | Code signing flags | | Executable | [FileInfo](#fileinfo) | Executable file information | | TTY | [FileInfoLight](#fileinfolight) | Associated TTY device | | StartTime | timestamp | Process start time | ### FileDescriptor An open file descriptor. | Field | Type | Description | | ------ | ------ | -------------------------------------------------------- | | FD | number | File descriptor number | | FDType | text | The type of file descriptor, e.g. `FD_TYPE_PIPE` | | PipeID | number | The unique ID of the pipe, when the descriptor is a pipe | ### SocketAddress A network socket address. | Field | Type | Description | | ------- | ----- | ------------------------------------------------- | | Address | bytes | The socket address | | Type | text | The address type, e.g. `SOCKET_ADDRESS_TYPE_IPV4` | ### GraphicalSession A graphical (windowed) session identifier. | Field | Type | Description | | ----- | ------ | ---------------------------- | | ID | number | Graphical session identifier | --- # Santa documentation ## Intro # Intro Santa is a high-performance open-source security agent for macOS that provides binary & file-access authorization and rich system event logging. --- ## Known limitations # Known limitations - Santa only blocks execution (execve and variants); it doesn’t protect against dynamic libraries loaded with dlopen, libraries on disk that have been replaced, or libraries loaded using `DYLD_INSERT_LIBRARIES`. - **Scripts:** Santa is written to ignore any execution that isn’t a binary. After weighing the administrative cost versus the benefit, we found it wasn’t worthwhile to manage the execution of scripts. Additionally, several applications make use of temporary scripts, and blocking these could cause problems. We’re happy to revisit this (or at least make it an option) if it would be useful to others. - **Removable Media (e.g. USB Mass Storage) Blocking:** Santa’s removable media blocking feature only stops incidental data exfiltration, it is not meant as a hard control. It operates at the mount level. It cannot block: - Directly writing to an unmounted, but attached device - **Network Mount Blocking:** Santa's network mount blocking feature requires macOS 15 or later. This feature is limited to Workshop customers. - Metrics reported by Santa are not _currently_ in a format that is friendly to open-source solutions --- ## Keys # Keys This page describes all of the available configuration options recognized by Santa. The configuration keys are broken down into sections to make it easier to find what you're looking for but in the configuration profile all the keys should be set together. Some keys (or available values for a key) will have a badge showing which Santa version they were added or deprecated in. Where a key has been deprecated, the description will list an alternative if one is available. A key with next to the type can be overridden by a sync server. ## General General options ## Sync Options related to syncing ## GUI Options controlling how the GUI functions ## FAA Options controlling file-access authorization ## Rules Options controlling binary authorization rules ## Telemetry Options controlling the output of telemetry data ## Removable Media (e.g. USB device) Options controlling the Removable Media (e.g. USB device) mount control feature ## Metrics Options controlling the export of agent metrics --- ## File-Access Authorization # File-Access Authorization File Access Authorization (FAA) policies are defined using a plist configuration file. The policy can be specified either in a [separate file](/configuration/keys#FileAccessPolicyPlist) or [in-line](/configuration/keys#FileAccessPolicy) with the rest of the Santa configuration. If the policy is specified in a separate file, Santa will periodically re-read this file. By default this will occur every 10 minutes but the interval can be [overridden](/configuration/keys#FileAccessPolicyUpdateIntervalSec). ## Policy Structure The policy file has a hierarchical structure with root-level configuration and individual watch rules. ### Root Level Keys - `Version` (required): Policy version identifier that will be reported in events - `EventDetailURL` (optional): URL displayed when users receive block notifications. Supports [variable substitution](#eventdetailurl-placeholders) (e.g., `%hostname%`, `%rule_name%`, `%file_identifier%`) - `EventDetailText` (optional): Button label text for the notification dialog, maximum 48 characters. Defaults to 'Open'. - `WatchItems` (optional): Dictionary containing the individual monitoring rules :::tip If you want a default URL and button text for all file access events without configuring them in every FAA policy, you can set the global [FileAccessEventDetailURL](/configuration/keys#FileAccessEventDetailURL) and [FileAccessEventDetailText](/configuration/keys#FileAccessEventDetailText) configuration keys. Per-policy `EventDetailURL` and `EventDetailText` values (and per-rule overrides) will take precedence over these global defaults. ::: ### Watch Item Structure Each entry in the `WatchItems` dictionary represents a single rule. The key for each entry is the rule name, which will be used in logs and in the block notification UI. :::info Rule names (the `WatchItems` dictionary keys) must be 1-64 characters long and match the regular expression `^[A-Za-z0-9._:-]+$`, containing only letters, digits, periods, colons, hyphens, and underscores. For example, `ChromeCookies`, `my_rule_1`, and `my-rule.v2` are valid, but `My Rule` and `rule=1` are not. Invalid names will be rejected and an error will be logged. ::: Each rule contains three main components: - `Paths`: Array of path patterns to monitor - `Processes`: List of allowed/denied processes with specific identifiers - `Options`: Settings for rule behavior ## Basic Example ```xml Version v0.1 EventDetailURL https://my-server/faa/%hostname%/%rule_name%/%file_identifier% WatchItems UserFoo Paths Path /Users/*/tmp/foo IsPrefix Options AllowReadAccess AuditOnly RuleType PathsWithAllowedProcesses Processes TeamID EQHXZ8M8AV SigningID com.google.Chrome.helper ``` ## Path Configuration Paths can be specified using exact matches or wildcard patterns: - Exact paths: `/etc/sudoers` - Wildcards: `/Users/*/Documents/*` Each path entry can include: - `Path` (required): The path pattern to monitor - `IsPrefix` (optional): Boolean indicating whether the path represents prefix matching. When `true`, the rule will match files nested inside directories. When `false` or omitted, wildcards only match files/directories at that level without recursing. :::important If a configuration contains multiple rules with overlapping configured paths, only one rule will be applied. Which rule will be applied is undefined, so take care not to define rules with duplicate paths. ::: ### Path Globs Path globs represent a point-in-time snapshot. Globs are expanded when a configuration is applied and periodically re-evaluated based on the [FileAccessPolicyUpdateIntervalSec](/configuration/keys#FileAccessPolicyUpdateIntervalSec) setting. When multiple path globs or prefixes match an operation, the rule with the "most specific" or longest match is applied. Glob pattern support is provided by the libc [`glob(3)`](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/glob.3.html) function. Extended glob patterns, such as globstar (`**`), are not supported. ### Path Resolution All configured paths are case-sensitive and must match the case as stored on the filesystem. Due to system limitations, Santa cannot reliably monitor hard-linked resources. To help mitigate bypasses, Santa will not allow the creation of hard links for monitored paths. If hard links previously existed for monitored paths, Santa cannot guarantee that access via these other links will be monitored. Configured path globs must refer to resolved paths only. Monitoring access on symbolic links is not supported. This is important as some common macOS paths are symbolic links (e.g., `/tmp` and `/var` are both symlinks into `/private`). ## Process Matching Processes can be matched using several identifiers: - **Signing ID**: Specified with the `SigningID` key (e.g., `EQHXZ8M8AV:com.google.Chrome.helper`) - **Team ID**: Specified with the `TeamID` key (e.g., `ZMCG7MLDV9`) - **Platform Binary**: Specified with the `PlatformBinary` boolean key - **CDHash**: Specified with the `CDHash` key (e.g., `397d55ebec87943ea3c3fe6b4d4f47edc490d25e`) - **Leaf Certificate Hash**: Specified with the `CertificateSha256` key - **Binary Path**: Specified with the `BinaryPath` key (e.g., `/Applications/Safari.app/Contents/MacOS/Safari`) :::tip Signing IDs must be scoped to a specific TeamID. You can use the same format as binary authorization rules where the SigningID is prefixed with the TeamID (e.g. `TeamID:SigningID`. For platform binaries, you can use the hard coded string `platform` as the TeamID (e.g. `platform:com.apple.yes`). ::: :::warning Specifying binaries by full path using `BinaryPath` is not very secure, as binaries can easily be moved. This should only be used as a last resort. Additionally, the `BinaryPath` key does not support glob patterns (`*`). ::: ## Rule Options The `Options` dictionary within each rule supports the following keys: - `RuleType` (required): Defines whether the rule is data-centric or process-centric: - `PathsWithAllowedProcesses`: Data-centric, only listed processes can access the paths - `PathsWithDeniedProcesses`: Data-centric, listed processes cannot access the paths - `ProcessesWithAllowedPaths`: Process-centric, listed processes can only access specified paths - `ProcessesWithDeniedPaths`: Process-centric, listed processes cannot access specified paths - `AllowReadAccess` (optional): Boolean controlling whether read access is allowed. When `false`, both read and write access are monitored/blocked. When `true`, only write access is monitored/blocked. Defaults to `true` if not specified. - `AuditOnly` (optional): Boolean. When `true`, violations are logged but not blocked. Defaults to `true`. - `EventDetailURL` (optional): Rule-specific URL that overrides the top-level EventDetailURL. - `EventDetailText` (optional): Custom button label text for this specific rule, overriding the root-level setting. - `BlockMessage` (optional): Custom message to be shown in the dialog presented to users upon a violation. Defaults to a reasonable, generic message that the action was blocked. - `EnableSilentMode` (optional): Boolean. When `true`, violations are logged but no notification is shown to the user. Defaults to `false`. - `EnableSilentTTYMode` (optional): Boolean. When `true`, violations are logged, but no notification is sent to the controlling TTY. Defaults to `false`. ## Rule Type Selection Choose your rule type based on what you're protecting: | Goal | Rule Type | | ----------------------------------------------------- | --------------------------------------------------------- | | Protect specific files/paths from unauthorized access | `PathsWithAllowedProcesses` or `PathsWithDeniedProcesses` | | Restrict what a specific process can access | `ProcessesWithAllowedPaths` or `ProcessesWithDeniedPaths` | **Data-centric example**: Protect browser cookies from theft by limiting access to the cookie files to only the browser processes. **Process-centric example**: Prevent AirDrop processes from reading files in folders containing sensitive corporate data. ## EventDetailURL placeholders When an FAA rule blocks access to a file, the user will be presented with a block notification dialog. On this dialog a button can be displayed which will take the user to a page with more information about that event. For the button to appear you must populate the `EventDetailURL` field, either at the top-level of the configuration or in an individual rule. This URL can contain placeholders, which will be populated at runtime; the supported placeholders are: | Placeholder | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `%rule_version%` | Version of the rule that was violated | | `%rule_name%` | Name of the rule that was violated | | `%file_identifier%` | SHA-256 of the binary that was being executed | | `%accessed_path%` | The path that was being accessed | | `%username%` | The executing user | | `%team_id%` | The team ID that signed this binary, if any | | `%signing_id%` | The signing ID of this binary, if any | | `%cdhash%` | The binary's CDHash, if any | | `%machine_id%` | The ID of the machine, usually the hardware UUID unless [overridden](https://northpole.dev/configuration/keys/#MachineID) | | `%serial%` | The serial number of the machine | | `%uuid%` | The hardware UUID of the machine | | `%hostname%` | The system's full hostname | ## More Information For complete example policies and use-cases, see the [File-Access Authorization feature documentation](/features/faa) and the [FAA cookbook](/cookbook/faa). --- ## Config Generator # Config Generator :::warning This generator is still under active development and there are known rough edges with some of the more complex configuration keys as well as more features that will be coming soon. Please give it a try! ::: Use this form to generate a valid Santa configuration, ready to put inside a configuration profile and deploy to your machines. The generator will ensure that the configuration is valid and help storing default values. :::info The generation is all done inside your browser; the data you input never leaves your machine. ::: ## General --- ## Sync --- ## GUI --- ## FAA --- ## Rules --- ## Telemetry --- ## Removable Media (e.g. USB mass storage device) --- ## Metrics --- ## Generate Click the button to generate and download the generated configuration file. --- ## Custom Branding # Custom Branding Santa can display your organization's name or logo on every window it shows, so that users can tell who manages the machine and who to contact. Branding is configured with three keys, all documented on the [Configuration: Keys](/configuration/keys) page: `BrandingCompanyName`, `BrandingCompanyLogo`, and `BrandingCompanyLogoDark`. All three were added in Santa 2026.1. When any of them is set, Santa adds a "Managed by:" footer to the bottom of all notification dialogs (e.g. execution blocked, file access blocked, network flow blocked). ## Company name `BrandingCompanyName` is the simplest option: the name is shown as text. ```xml BrandingCompanyName Acme Corporation ``` Block dialog branded with a company name Block dialog branded with a company name ## Company logo `BrandingCompanyLogo` replaces the name with an image. The image is scaled down to fit within 84x28 points, so a wide wordmark works better than a tall or square logo. Supply the artwork at twice that size so that it stays sharp on a Retina display. Only the `file://` and `data:` URL schemes are supported. HTTP and HTTPS URLs are not, as Santa will not fetch a logo over the network: ```xml BrandingCompanyLogo file:///Library/Application%20Support/Acme/logo.png ``` If you use a `file://` URL, deploy the image alongside the profile and put it somewhere that is readable by all users. Otherwise, embed the image directly in the profile with a `data:` URL: ```xml BrandingCompanyLogo data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAA4CAYAAAC... ``` ### Dark mode Santa's windows follow the user's appearance setting, so a single-color logo that reads well on a light background can disappear on a dark one. `BrandingCompanyLogoDark` is used instead of `BrandingCompanyLogo` whenever the window is drawn in dark mode: ```xml BrandingCompanyLogo file:///Library/Application%20Support/Acme/logo.png BrandingCompanyLogoDark file:///Library/Application%20Support/Acme/logo-dark.png ``` The example below uses a dark wordmark for light windows and a light one for dark windows. **Light appearance** Block dialog in light mode branded with a dark company logo **Dark appearance** Block dialog in dark mode branded with a light company logo ## Precedence Only one piece of branding is ever displayed. The keys are evaluated in this order: | Order | Key | Used when | | ----- | ------------------------- | --------------------------------------------- | | 1 | `BrandingCompanyLogoDark` | The window is in dark mode and the key is set | | 2 | `BrandingCompanyLogo` | The key is set | | 3 | `BrandingCompanyName` | Neither logo key applies | A logo URL that uses any other scheme is ignored, as if the key was not set at all. If a logo URL is accepted but the image cannot be loaded - for example the file is missing, or is not an image format that macOS can read - Santa falls back to `BrandingCompanyName`. Set that key alongside the logo keys so that there is always something to display. ## Terminal messages Blocks that happen in a terminal are also branded, but only ever with `BrandingCompanyName`, as logos cannot be drawn on a TTY: ```text Santa The following application has been blocked from executing because its trustworthiness cannot be determined Reason: No matching rule Path: /Applications/Malware.app/Contents/MacOS/Malware Identifier: 60055b1f6fb276bfacf61f91505a72201987f20ad8b6867cce3058f4c0f0f5e5 Parent: bash (2511) Managed by: Acme Corporation ``` ## Custom messages Branding covers who manages the machine. To change what the dialogs _say_, see the `UnknownBlockMessage`, `BannedBlockMessage`, `FileAccessBlockMessage`, `BannedUSBBlockMessage`, `EventDetailURL`, and `EventDetailText` keys on the [Configuration: Keys](/configuration/keys) page. Rules synced from a server can also carry their own message and URL, which override the configured defaults. --- ## Common Expression Language (CEL) # Common Expression Language (CEL) This page lists well-known and/or community-contributed CEL expressions. CEL ([Common Expression Language](https://cel.dev/)) rules allow for more complex policies than would normally be possible. Read how to configure CEL rules in the [Binary Authorization](/features/binary-authorization#cel) documentation. ## Apps signed since X This will prevent executions of an app where the specific binary was signed before the provided date. This is particularly useful when attached to a `TEAMID` or `SIGNINGID` rule. ```clike target.signing_time >= timestamp('2025-05-31T00:00:00Z') ``` = timestamp('2025-05-31T00:00:00Z')`} context={` target: signing_time: "2025-06-01T00:00:00Z" args: - "--version" envs: HOME: "/Users/admin" euid: 501 cwd: "/Applications" `} /> ## Apps signed within the last N days This allows executions only when the binary was securely signed within a sliding window — here, the last 90 days — and blocks anything older. Unlike a fixed `timestamp(...)`, the window moves forward automatically each day, so the rule never needs to be re-pushed. `today()` is the start of the current day in the host's time zone (before Santa 2026.8, the current UTC day) and `days(n)` is `n`×24h (the standard `duration()` only parses units up to hours). This requires [Workshop](https://northpole.security/), and because `today()` changes daily the result is not cached. ```clike target.secure_signing_time > today() - days(90) ``` The example binary below was signed in 2020, so it falls outside the window and is blocked: today() - days(90)`} context={` target: secure_signing_time: "2020-01-01T00:00:00Z" args: - "--version" envs: HOME: "/Users/admin" euid: 501 cwd: "/Applications" `} /> ## Allow an app only during working hours {#working-hours} Attach this to a Signing ID or Team ID rule for the application. It allows the app from 09:00 to 17:00, Monday through Friday, on each host's own clock, and blocks it at any other time. See [Time Based Rules](/features/time-based-rules) for the other window forms and for what happens at the edges of a window. Requires [Workshop](https://northpole.security/) 2026.8 or later and Santa 2026.8 or later, and because the window is checked at every execution the result is not cached. ```clike policy_for_range(weekdays(), "09:00", "17:00", ALLOWLIST, BLOCKLIST) ``` The example below evaluates the rule at 10:30 on a Monday, so the execution is allowed. Change `now` to an evening or a weekend to see the block: ## Working hours with Touch ID outside them {#working-hours-touchid} Out-of-hours use stays possible with a person at the keyboard. The cooldown means one approval covers the next hour. Requires [Workshop](https://northpole.security/) 2026.8 or later and Santa 2026.8 or later. ```clike policy_for_range(weekdays(), "08:00", "18:00", ALLOWLIST, require_touchid_with_cooldown_minutes(60)) ``` The example below evaluates the rule at 21:15 on a Monday, so Touch ID is required: ## Audit out-of-hours use before enforcing {#audit-before-enforcing} Both policy slots allow the process to run. 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. Requires [Workshop](https://northpole.security/) 2026.8 or later and Santa 2026.8 or later. ```clike policy_for_range(weekdays(), "09:00", "17:00", ALLOWLIST, AUDIT) ``` The example below evaluates the rule on a Saturday afternoon, so the execution is allowed and flagged for audit: ## One maintenance window for the whole fleet {#fleet-maintenance-window} With a named time zone every host reads the same calendar, so this window is the same four hours everywhere regardless of each host's own zone. The zone can be `"UTC"`, an IANA name such as `"America/New_York"`, or a fixed offset such as `"+05:30"`. Requires [Workshop](https://northpole.security/) 2026.8 or later and Santa 2026.8 or later. ```clike policy_for_range([0, 1, 2, 3, 4, 5, 6], "01:00", "05:00", "UTC", ALLOWLIST, BLOCKLIST) ``` The example below runs on a host in New York at 23:30 local time, which is 03:30 UTC, so the execution is allowed: ## Quit an app when the shift ends {#quit-at-end-of-shift} Without `kill_on_expiry()` a window governs new executions only, and a process started inside the window keeps running after it closes. Wrapping the in-range policy records every execution the rule allows during the window and quits whatever is still running at 17:00, after warning the user 48 minutes earlier. See [Quitting Processes When the Window Closes](/features/time-based-rules#kill-on-expiry) for what Santa records and how the quit is delivered. Requires [Workshop](https://northpole.security/) 2026.8 or later and Santa 2026.8 or later. ```clike policy_for_range(weekdays(), "09:00", "17:00", kill_on_expiry(ALLOWLIST), BLOCKLIST) ``` The example below evaluates the rule at 15:45 on a Monday. The execution is allowed, and the playground shows the quit that is recorded for 17:00: ## Timed access counted from launch {#timed-access} The duration form opens a window at the moment of the execution, so it is always in range and exists only to set an expiry, which is why `kill_on_expiry()` is required with it. Here the user is asked for Touch ID first, and the process is quit 30 minutes after it started. A second launch inside those 30 minutes shares the first one's deadline. Requires [Workshop](https://northpole.security/) 2026.8 or later and Santa 2026.8 or later. ```clike policy_for_range(duration("30m"), kill_on_expiry(require_touchid_with_cooldown_minutes(30))) ``` To time only some launches, put the call in a branch of a ternary. Ordinary launches are allowed at any hour and only launches with `--beta` are timed: ```clike "--beta" in args ? policy_for_range(duration("30m"), kill_on_expiry(ALLOWLIST)) : ALLOWLIST ``` ## Prevent users from disabling gatekeeper Create a signing ID rule for `platform:com.apple.spctl` and attach the following CEL program ```clike [ '--global-disable', '--master-disable', '--disable', '--add', '--remove' ].exists(flag, flag in args) ? BLOCKLIST : ALLOWLIST ``` ## Prevent Timestomping of LaunchAgents and LaunchDaemons Malware like those produced by the Chollima groups use "timestomping" to reset the timestamps of LaunchAgents and LaunchDaemons using touch. This can be prevented / detected by creating a SigningID rule for `platform:com.apple.touch` with the following CEL program. This technique was recently discussed by [Jaron Bradely](https://themittenmac.com/author/jaron-bradley/) at [Objective by the Sea v8](https://objectivebythesea.org/v8/talks.html#Speaker_24) ```clike args.exists(arg, arg in [ '-a', '-m', '-r', '-A', '-t' ]) && args.join(" ").contains("Library/Launch") ? BLOCKLIST : ALLOWLIST ``` Note this will not stop using the system calls directly or otherwise programmatically modifying the timestamps. Also this won't cover modifications if the process' current working directory is already in the LaunchDaemons / LaunchAgents directories. ## Prevent OSAScript From Popping Password Dialogs A lot of malware on macOS will attempt to get users to enter their passwords into a dialog box via osascript. This is a basic rule to stop directly asking for a password dialog. Make a SigningID rule for `platform:com.apple.osascript` with the following CEL Program ```clike ( args.join(" ").lowerAscii().matches(".*\\W+with\\W+hidden\\W+answer.*") || args.join(" ").lowerAscii().contains("password") ) && args.join(" ").lowerAscii().matches( ".*\\W+display\\W+dialog.*") ? BLOCKLIST : ALLOWLIST ```