Each numbat rule is one YAML file with CEL boolean expressions. It evaluates either one normalized event or an ordered sequence of events.
Rule expressions can use:
event, a stable projection of normalized action and context fields using emitted JSON names.shell_commands, a rule-only parsed view of commands found inevent.command.
Embedded rules are monitor-only by default. See the
built-in catalog for coverage and rules/ for
the authoritative YAML.
Create one rule and an optional companion test:
my-rules/
env_read.yaml
env_read_tests.yaml
# env_read.yaml
id: acme.secrets.env_read
version: "1.0"
title: Environment file read
severity: high
expr: |-
event.event_type == "file.read" &&
event.file_path.matches("(^|/)\\.env$")
tags: [secret_file_read]# env_read_tests.yaml
rule_id: acme.secrets.env_read
cases:
- name: env-file
expect: match
events:
- event_type: file.read
file_path: /repo/.env
- name: example-file
expect: no_match
events:
- event_type: file.read
file_path: /repo/.env.exampleValidate the rule and its companion tests:
numbat rules check --rules-dir ./my-rulesUse --no-builtin-rules when the directory should be evaluated alone:
numbat rules check --no-builtin-rules --rules-dir ./my-rulesUse the simplest input that preserves the distinction your rule needs:
| Need | Use |
|---|---|
| File, URL, MCP, approval, model, or other normalized fields | event.<field> |
| A literal check over the original command text | event.command |
| Prompt, assistant, or source-recorded reasoning text | event.content |
| Parsed executable names, arguments, pipes, redirects, or wrappers | shell_commands |
| Ordered activity across multiple events | sequence |
Rules may use event.command; it is supported and not deprecated:
event.event_type == "command.exec" &&
event.command.contains("terraform destroy")
Raw text is appropriate when literal text is the signal. It can also match
comments, quoted examples, commit messages, or here-doc data, so prefer
shell_commands when a match should depend on executable command semantics.
This distinction matters most for rules that may block.
Longer built-in expressions encode narrow false-positive exclusions; they are policy implementations, not a required template for custom rules.
A rule file is one top-level YAML object. A rules: wrapper, multiple YAML
documents, and multiple rules per file are rejected.
| Field | Required | Meaning |
|---|---|---|
id |
yes | Stable, dot-separated rule ID |
version |
yes | Rule-owned version copied to findings |
title |
yes | Finding title |
severity |
yes | info, low, medium, high, or critical |
expr |
one of | CEL predicate for a single event |
sequence |
one of | Ordered multi-event definition |
description |
no | Operator-facing explanation |
tags |
no | Strings copied to findings |
enabled |
no | Defaults to true; disabled rules are still validated |
enforce |
no | Defaults to false; permits blocking on supported hooks |
deny_message |
no | Single-line blocking response, at most 512 UTF-8 bytes |
Set exactly one of expr and sequence.
Rule IDs use lowercase letters, digits, _, and - in dot-separated
segments. Each segment must start and end with a letter or digit. Give new
operator rules an organization-specific namespace, such as acme.*, to avoid
future built-in collisions.
Each rule owns its version. It is independent of numbat's record
schema_version; bump it whenever rule logic changes, including a
false-positive fix. numbat does not impose a versioning scheme.
Built-ins additionally require description, explicit enabled, and at least
one behavior or ATT&CK tag. Those are catalog review policies, not requirements
for operator rules.
An expression must return a boolean and should begin by selecting the event types it understands. Fields differ by event type, and pre-action events describe requested actions rather than confirmed outcomes.
Common CEL operations include:
| Operation | Example |
|---|---|
| Boolean logic | a && b, `a |
| Membership | value in ["a", "b"] |
| String tests | contains, startsWith, endsWith, matches |
| List predicates | exists, all, exists_one |
| List range | items.slice(start, end) |
| Integer indexes | lists.range(n).exists(i, ...) |
| Missing nullable value | event.exit_code == null |
| Path spelling | canonical_path(event.file_path).matches("^/etc/sudoers$") |
matches uses RE2 regular expressions. CEL string literals require their own
escaping; for example, a literal dot is written as "\\.env".
Action types are alternatives, not layers. A recognized shell action is a
command.exec, not both a tool.call and a command.exec; file and network
actions are specialized the same way. tool.call is the fallback when numbat
cannot safely specialize the action. A later command.result is the correlated
outcome, not a second invocation.
Long-running commands may produce multiple command.result updates with the
same tool_call_id. A missing exit_code means the source did not report one;
it does not imply success.
canonical_path is a lexical comparison helper. It removes . and ..
segments and repeated slashes, and strips /proc/<pid>/root and
/proc/<pid>/task/<tid>/root prefixes (including self and thread-self),
repeating both steps until the spelling is stable. The first segment of a
relative spelling such as ~ or $HOME is kept, so ~/../~/.ssh does not
become ~/.ssh; an option prefix such as --file=/ is kept and the rest is
cleaned as an absolute path. A spelling with no /, or one that still contains
$ or a backtick after its first segment, is returned unchanged, because an
unexpanded value may span any number of directories. It does not expand
variables, distinguish quoted from unquoted operands, resolve a relative path
against the working directory, or follow symlinks. It has no Windows
semantics: any slash-prefixed string gets POSIX cleaning, a drive-letter
operand is handled like any relative path (first segment kept, the rest
cleaned), and backslashes are never separators. Use command.arguments when
quoting or expansion matters.
Do not add tool.call as a catch-all branch to a command rule. Generic tool
calls have no event.command; write a separate tool rule only when the generic
tool identity is itself the signal.
For command activity, choose the events you mean. Hooks and artifacts normally
provide the requested action as command.exec. Some OTLP sources expose only a
completed command.result, so shipped command rules commonly use:
event.event_type == "command.exec" ||
(event.source_type == "otel" && event.event_type == "command.result")
That gate avoids matching both the request and result for sources that emit
both. A custom rule may use only command.exec when it cares strictly about
requested actions, or only command.result when completion matters.
For conversation events, event.content is the unredacted mapped message text,
bounded to 1 MiB per event. A rule that references a content field automatically
enables this analysis; --content full is needed only to emit it. Use
event.content_truncated when a rule must reject incomplete input.
event.content_bytes is the mapped body size before Numbat's bound. These
fields never include file bodies, patches, or arbitrary tool output.
Tool actions and results expose the original source payloads through
event.tool_input and event.tool_result, independently of the output mode.
These strings contain JSON values, preserving objects, arrays, scalars and
explicit null; a missing payload is "", while a recorded empty string is
"\"\"". Specialized command, file and network events retain their tool
payloads too. File contents and patches can therefore occur in these fields.
Each payload is bounded to 16 MiB. The corresponding *_bytes field counts
serialized JSON bytes before that bound; *_truncated reports an incomplete
payload. These flags describe Numbat's retention, not upstream completeness.
Reading an incomplete tool payload produces a scoped evaluation error rather
than allowing a negative check to treat its missing tail as clean. This also
applies when replaying preview records with a byte count but no body. Rules
can inspect the truncation flags without reading the body. Content rules use
the bounded CEL evaluation budget. Output redaction never changes local rule
input; redacted exports cannot reconstruct original values during replay.
Simple colon-form shell parameter operators such as ${GH_TOKEN:-} are
preserved, but redacted commands are not lossless shell source.
event.content_omitted lists bodies withheld by the output limit or shared with
another event from the same call. Reading a listed body during replay also
produces a scoped evaluation error.
The CEL event map always contains every key below. exit_code, duration_ms,
and approval_required use null when absent; strings use "", diff_bytes
uses 0, and tags uses an empty list.
| Field | Type | Field | Type |
|---|---|---|---|
event.actor |
string | event.approval_decision |
string |
event.approval_reason |
string | event.approval_required |
bool |
event.cli_version |
string | event.command |
string |
event.confidence |
string | event.content |
string |
event.content_bytes |
int | event.content_preview |
string |
event.content_preview_truncated |
bool | event.content_truncated |
bool |
event.decision |
string | event.diff_bytes |
int |
event.diff_sha256 |
string | event.duration_ms |
int |
event.entrypoint |
string | event.event_type |
string |
event.exit_code |
int | null | event.file_path |
event.git_branch |
string | event.mcp_server |
string |
event.mcp_tool |
string | event.model |
string |
event.model_provider |
string | event.parent_session_id |
string |
event.project_path |
string | event.session_id |
string |
event.session_tree_id |
string | event.source_agent |
string |
event.source_type |
string | event.sub_agent |
string |
event.sub_agent_id |
string | event.tags |
list(string) |
event.timestamp |
string | event.tool_call_id |
string |
event.tool_input |
string | event.tool_result |
string |
event.tool_input_bytes |
int | event.tool_result_bytes |
int |
event.tool_input_truncated |
bool | event.tool_result_truncated |
bool |
event.tool_name |
string | event.url |
string |
event.content_omitted |
list(string) |
The event schema defines closed values
for fields such as source_agent, source_type, actor, decision, and
confidence.
For sub-agent rules, use session_id for the active thread and
sub_agent_id for stable child identity. sub_agent is display context and
may be shared by concurrent children. Tree and parent joins are available only
when the source reports session_tree_id or parent_session_id; see the event
model.
Use event.exit_code != null, not has(event.exit_code). The key is always
present even when the value is null.
Rule loading rejects unknown fields and computed access. Use direct field
syntax such as event.command.
Context fields such as source, timestamp, project, session, parent/tree,
actor, model, branch, entrypoint, sub-agent, preview, tags, and confidence are
valid on every event type. Full content fields are valid only on conversation
events.
Non-empty action fields follow this compatibility table:
| Event type | Allowed action fields |
|---|---|
session.start, session.end, prompt.user, message.assistant, message.reasoning, config.agent |
none |
tool.call |
tool_name, tool_call_id, mcp_server, mcp_tool, url, file_path, decision |
tool.result |
tool_name, tool_call_id, mcp_server, mcp_tool, decision |
command.exec |
command, tool_name, tool_call_id, decision |
command.result |
command, tool_name, tool_call_id, exit_code, duration_ms, decision |
file.read |
file_path, tool_name, tool_call_id, decision |
file.write |
file_path, tool_name, tool_call_id, decision, diff_sha256, diff_bytes |
file.delete |
file_path, tool_name, tool_call_id, diff_sha256, diff_bytes |
permission.requested, permission.approved, permission.denied |
tool_name, tool_call_id, decision, approval_required, approval_decision, approval_reason |
config.mcp |
mcp_server, mcp_tool |
network.indicator |
url, tool_name, tool_call_id, mcp_server, mcp_tool, decision |
Companion fixtures are normalized and validated against the same contract.
shell_commands is a list of statically identified POSIX shell, PowerShell,
and cmd.exe commands. It is derived from event.command during rule
evaluation and is never emitted.
A basic command rule usually needs only name and argv:
id: acme.network.curl_upload
version: "1.0"
title: Curl upload requested
severity: high
expr: |-
event.event_type == "command.exec" &&
shell_commands.exists(command,
command.name == "curl" &&
command.argv.slice(1, command.argv.size()).exists(arg,
arg in ["--upload-file", "-T"]
)
)name is the lowercase executable basename with common Windows executable
suffixes removed. argv includes the executable at index 0.
Match an option:
command.argv.size() > 1 &&
command.argv.slice(1, command.argv.size()).exists(arg,
arg in ["--force", "-f"]
)
Match an option followed by its value:
command.argv.size() > 1 &&
lists.range(command.argv.size() - 1).exists(i,
command.argv[i] == "--output" &&
command.argv[i + 1] == "/tmp/result"
)
Match two commands in the same pipeline:
shell_commands.exists(download,
download.name in ["curl", "wget"] &&
download.pipeline_id > 0 &&
shell_commands.exists(interpreter,
interpreter.statement_id != download.statement_id &&
interpreter.pipeline_id == download.pipeline_id &&
interpreter.name in ["sh", "bash", "zsh"]
)
)
The nested exists does not introduce a new rule construct. It asks whether
one parsed command and a second parsed command satisfy the stated relationship.
The built-in pipe-to-shell rule adds
argument checks because it distinguishes interpreters that execute standard
input from ones given a local script.
Match a launcher around a command:
command.wrappers.exists(wrapper, wrapper.name in ["sudo", "doas"])
Match an output redirect:
command.redirects.exists(redirect,
redirect.op in ["write", "append"] &&
redirect.target.endsWith("/authorized_keys")
)
Most rules need only name, argv, and, for pipelines, the relationship IDs.
| Field | Type | Meaning |
|---|---|---|
name |
string | Normalized executable basename |
executable |
string | Statically parsed executable value |
argv |
list(string) | Parsed, non-runtime-expanded values including the executable |
arguments |
list(argument) | argv plus source, quoting, and expansion data |
assignments |
list(assignment) | Command-scoped shell assignments |
redirects |
list(redirect) | Input and output redirections |
dialect |
string | posix, powershell, or cmd |
wrappers |
list(wrapper) | Recognized launchers around the command |
statement_id |
int | This command's relation ID |
pipeline_id |
int | Shared by commands in one pipeline; 0 means none |
parent_statement_id |
int | Enclosing command relation; 0 means none |
preview |
string | normal, preview, or uncertain |
function_call |
bool | Statically resolved shell function call |
recursive |
bool | Statically resolved recursive function call |
Relationship IDs are local to one event. Compare them for equality; do not
interpret their numeric values beyond 0 meaning no relationship.
Nested object fields:
| Object | Fields |
|---|---|
argument |
value, source, quote, expands, subcommands |
assignment |
name, value, append |
redirect |
fd, op, target, target_source, target_quote, target_expands, subcommands |
wrapper |
name, executable, argv |
argument.quote and target_quote are none, single, double, or mixed.
subcommands contains relation IDs for statically identified nested commands.
Redirect operations are read, write, append, read_write, here_doc,
duplicate, or close; fd: -1 means a combined stdout/stderr redirect.
source and target_source preserve token spelling; value and target hold
parsed text without runtime expansion. The corresponding expands flag marks
syntax whose final value depends on runtime state. Use arguments instead of
argv when quoting or expansion determines whether an operand is active.
The parser identifies only statically visible commands. It does not execute
code or read referenced scripts. A POSIX command name given by one plain
variable ($g, "$g", ${g}) resolves only when the immediately preceding
statement in the same list is a single static scalar assignment to that
variable and the script contains nothing that can change variables behind the
analyzer's back, such as trap, eval, source, declare, export,
readonly, set, IFS, or arithmetic assignment; this list is not
exhaustive and inline child scripts do not inherit assignments.
Shell-managed names such as RANDOM, OPTIND, BASH_SUBSHELL, and
BASH_MONOSECONDS also stay unresolved: an assignment is not proof of their
runtime value.
Other variables stay unexpanded. Comments and quoted examples do
not become commands. Data-only literal here-docs stay inert; a here-doc
supplied to an interpreter such as sh is executable input and is parsed.
Supported static wrappers, inline scripts, substitutions, redirects, and
shell functions are projected when their meaning can be established.
Recognized PowerShell launcher options accept case-insensitive, single-dash
prefixes such as -encod and -comm; host-specific legacy options retain exact
spelling.
Known tool_name values select POSIX shell, PowerShell, or cmd.exe parsing;
otherwise numbat infers the dialect from command syntax. Shared control-flow
prefixes such as if ( prefer POSIX when the complete command parses as POSIX.
Set tool_name in a fixture when a Windows command depends on a specific dialect.
If part of a command is dynamic, malformed, unsupported, or over a parser
bound, numbat emits a diagnostic. Proven independent commands remain available
for detection. If no usable projection remains, rules that reference
shell_commands skip that event.
Detection recognizes explicit PowerShell -WhatIf requests and a statically
visible $WhatIfPreference = $true for known cmdlet names and exact
module-qualified forms. Ambient preference and command-resolution state are not
inferred.
For a shell-derived match, blocking has a narrower eligibility boundary than
detection: every command in the shell program must be static. POSIX simple
commands joined by lists, pipelines, subshells, groups, conditionals, loops,
negation, or background syntax are eligible; PowerShell and CMD input must be
one simple command. Supported transparent launchers are allowed only when
their final child command is also in that subset. Heredocs, redirects attached
to compound statements, substitutions or expansions in loop words and case
words, same-script functions, inline child interpreters, eval or
Invoke-Expression, runtime-dependent values, PowerShell or CMD pipelines,
previews, parser diagnostics, and truncated projections make the whole input
detection-only. A rule may still use shell_commands alongside structured
fields such as event.file_path; a matching commandless structured event does
not require a shell projection. See Enforcement.
Set enforce: true to make a rule eligible for blocking when a supported
pre-action hook is installed in enforce mode. Severity remains independent:
it prioritizes the finding; it does not decide whether a rule may block.
Enforcement uses the same CEL expressions as detection; there is no separate
rule language or required predicate shape. Raw event.command remains
available, but it matches literal input and can therefore match text that the
shell would not execute. Use shell_commands when blocking depends on parsed
command semantics. A shell-derived match can detect broad shell syntax, but a
deny requires the complete shell program to be inside the static subset
described above.
All built-ins ship monitor-only. To enforce one, copy its complete YAML into an
operator directory, keep the same ID, set enforce: true, and bump the rule
version. --enforce alone never converts monitor-only findings into denies.
deny_message overrides the generic message returned to the agent:
enforce: true
deny_message: Contact the workspace owner.The deny response does not include rule IDs or evidence. Those remain in the finding. See Enforcement for hook support, decision timing, and fail-open behavior.
numbat rules check automatically runs an adjacent companion with the same
extension and a reserved _tests suffix:
agent_read_env.yaml
agent_read_env_tests.yaml
rule_id: secrets.agent_read_env
cases:
- name: direct-read
expect: match
events:
- event_type: file.read
file_path: /repo/.env
- name: benign-readme
expect: no_match
events:
- event_type: file.read
file_path: /repo/README.mdEach case uses expect: match or expect: no_match. Events use emitted JSON
field names. Fixtures may omit schema_version, event_id, source_agent,
source_type, confidence, and evidence; the test runner supplies defaults
and validates the resulting event.
For an enforce-enabled rule, an optional assertion checks enforcement eligibility as well as detection:
- name: powershell-preview
expect: match
expect_enforcement: false
events:
- event_type: command.exec
tool_name: PowerShell
command: Stop-Process -Name target -Force -WhatIfexpect_enforcement is valid only with expect: match. It tests rule-engine
eligibility, not whether a particular host or hook mode would block the action.
For an enforce-enabled rule that uses shell_commands, include at least one
eligible case and one detection-only case.
For an ad hoc NDJSON fixture:
numbat rules test \
--rules-dir ./my-rules \
--fixture positive.ndjson \
--expect acme.secrets.env_read
numbat rules test \
--rules-dir ./my-rules \
--fixture negative.ndjson \
--expect-noneUnlike companion fixtures, NDJSON fixtures receive no defaults. Each line must be a valid normalized event object; emitted event records can be used directly. See the event schema for required fields.
A sequence rule declares sequence instead of expr. Each step is an ordinary
CEL predicate evaluated against one event:
id: acme.chain.env_read_then_curl
version: "1.0"
title: Environment file read followed by curl
severity: high
sequence:
within_events: 64
steps:
- expr: |-
event.event_type == "file.read" &&
event.file_path.matches("(^|/)\\.env$")
- expr: |-
event.event_type == "command.exec" &&
shell_commands.exists(command, command.name == "curl")| Sequence field | Meaning |
|---|---|
steps |
Two to eight ordered CEL predicates |
within |
Optional wall-clock window in Go duration syntax, such as 30m |
within_events |
Optional event-count window, up to 4096 |
max_matches |
Findings per rule and correlation partition, default 1, maximum 16 |
Set within, within_events, or both. When both are present, both must hold.
within_events must be at least the number of steps.
A completed sequence emits one finding citing each contributing event in order. Its confidence is the weakest contributing event's extraction confidence. Finding identity is deterministic over the rule ID, rule version, and cited event IDs.
Sequence matching is conservative:
- Events stay within one agent, source, session, and project partition. Artifact scans also stay within one source file. Live events without a session ID do not correlate across events.
- Steps match only in declared order.
- The tracker chooses one canonical chain and emits at most
max_matchesfindings per partition. This cap never suppresses enforcement. withinrequires parseable, ordered endpoint timestamps.- State is bounded. Overflow misses a chain rather than growing without limit.
scan, collect, and rules test keep sequence windows in memory. One-shot
hook callbacks persist their bounded window in $HOME/.numbat/state.db
(--state-db overrides the path). A change to the loaded sequence-rule set
that affects matching, enforcement, or rule version invalidates old windows.
Parser and projection updates also invalidate old verdicts; the current
projection revision is sequence-projection-v10. If
the state cannot be opened, correlation fails open with a warning.
An enforced sequence may block only the action matching its final step. Earlier steps have already been observed. Scan, OTLP, post-action, and monitor mode remain detection-only. Use result events in a detection-only sequence when confirmed completion matters.
Companion tests for sequence rules list the ordered events inside one case.
Pass --rules-dir DIR more than once to load multiple operator directories.
numbat walks each directory recursively in path order and skips companion test
files.
numbat scan --rules-dir ./team-rules --rules-dir ./host-rules
numbat rules list --rules-dir ./team-rules
numbat rules list --no-builtin-rules --rules-dir ./team-rulesA new ID adds a rule. An operator rule with the same ID as a built-in replaces that built-in completely; fields are not merged. Copy the complete YAML, make the change, and bump its version. An invalid replacement fails the load and never falls back silently.
Operator IDs must be unique across all supplied directories. A replacement
with the same ID and enabled: false disables the built-in. Disabled rules are
validated but absent from rules list, which prints the effective compiled
catalog.
For fleet policy, deploy a versioned operator directory separately from the
binary. Installed hook callbacks reload it on each invocation; a running
collect process must restart after a change. Keep policy files outside agent
workspaces and read-only to the monitored user when possible. numbat validates
rules but does not verify signed rule bundles:
numbat hook install --agent claude --rules-dir /etc/numbat/rulesSee Deployment for scope, permissions, trust, and rollout guidance.
Custom builds may instead edit rules/<group>/*.yaml; those files become part
of the embedded catalog and require a new binary for every policy change.
Rule loading is strict. Missing metadata, invalid IDs or severity, unknown YAML
keys, duplicate IDs, invalid CEL, unknown event fields, and nonexistent or
empty --rules-dir paths fail the command. Use numbat rules check in CI
before deploying a catalog.