Authoring CEL rules

Write portable DefenseClaw CEL expressions against typed ActionFacts, validate them, and test their connector coverage safely.

Shell examples

The shell examples require Bash. On native Windows, use Git Bash or WSL; PowerShell uses different continuation and environment-variable syntax.

CEL rules are Boolean matchers inside guardrail rule packs. A rule combines a typed expression for authoritative tool-call facts with a required Go/RE2 pattern for compatible fallback detection.

For a worked rule, see write a custom rule. Use this page to adapt it to your command, path, network, or data-flow case. The CEL engine reference is normative for field types, admitted syntax, costs, and runtime behavior.

Rule-file contract

Put additive organization rules in a file with a unique category. A category that matches a bundled category replaces that entire category; a unique name adds rules without shadowing shipped groups such as command or sensitive-path.

Every field has a distinct job:

FieldContract
versionRule-file schema version. Use 1.
categoryFile-level catalog group. Use a unique organization category unless replacing a complete bundled category intentionally.
idStable finding and semantic-owner ID. Custom rules claim their own ID; keep IDs unique.
expressionBoolean CEL expression with no surrounding whitespace. It is compiled even when the rule is disabled.
patternRequired Go/RE2 fallback. It preserves detection when the connector cannot supply authoritative structured facts.
tool_call_onlyRestricts the regex fallback to trusted tool calls. CEL is always tool-call-only whether this is true or omitted.
title, severity, confidence, tagsFinding metadata. Severity is LOW, MEDIUM, HIGH, or CRITICAL; confidence is from 0 to 1; tags must be non-empty.

CEL returns only true or false; it does not return a verdict. A true result creates the YAML-defined finding. Connector mode, severity thresholds, HITL, and enforcement evidence determine the resulting action.

A pattern preserves detection, not authority

pattern and tool_call_only control where compatible regex detection runs; they do not authorize a trusted-action block. A custom CEL rule may enforce only when complete authoritative ActionFacts match its expression and the final code-owned proof is pinned to that same rule ID. Raw regex, partial or invalid projection, parser-shadow, and mismatched proof remain visible as detection-only evidence.

For fallback-pattern design and counterexamples, use the RE2 regex cookbook.

Authoring patterns

Prefer semantic operations and joined facts over connector-specific tool names. Use c.program for normalized program identity; c.executable preserves the invocation token or path. f.tool can vary across connectors.

Require a complete argument vector before making an argument-sensitive claim:

CEL expression
f.commands.exists(c,
  c.argv_complete &&
  c.program in ['curl', 'curl.exe', 'wget', 'wget.exe'] &&
  defenseclaw.guardrail.semantic.v1.OperationKind.OPERATION_KIND_FETCH
    in c.operations)

A command whose program DefenseClaw doesn't model, such as hostname or most third-party tools, has a known argv but no known meaning for its operands, so its paths and operations are unknown. A rule that reads only c.program, c.argv and c.argv_complete (inside exists() or in) still matches it and can block. A rule that also needs its paths or operations doesn't match it.

Join the path to its command and use a boundary-safe directory check:

CEL expression
f.commands.exists(c,
  f.paths.exists(p,
    p.command_id == c.id &&
    p.access == defenseclaw.guardrail.semantic.v1.PathAccess.PATH_ACCESS_WRITE &&
    (p.resolved == '/etc/systemd/system' ||
     p.resolved.startsWith('/etc/systemd/system/'))))

resolved and active_home may be empty when the connector cannot prove host context. Use normalized or absolute deliberately when resolution is not a requirement; never assume an empty value means a safe value.

Match an upload to a public or unresolved hostname:

CEL expression
f.commands.exists(c,
  defenseclaw.guardrail.semantic.v1.OperationKind.OPERATION_KIND_UPLOAD
    in c.operations &&
  f.network.exists(n,
    n.command_id == c.id &&
    n.action == defenseclaw.guardrail.semantic.v1.NetworkAction.NETWORK_ACTION_UPLOAD &&
    n.scope in [
      defenseclaw.guardrail.semantic.v1.NetworkScope.NETWORK_SCOPE_PUBLIC,
      defenseclaw.guardrail.semantic.v1.NetworkScope.NETWORK_SCOPE_UNKNOWN
    ]))

DefenseClaw does not perform DNS resolution while building facts. A hostname can therefore remain UNKNOWN; include that scope when the rule should cover external hostnames that are not literal public IP addresses.

Join the command to both edges of a file-to-network flow:

CEL expression
f.commands.exists(c,
  f.data_flows.exists(read,
    read.to_command_id == c.id &&
    read.from == defenseclaw.guardrail.semantic.v1.DataKind.DATA_KIND_FILE &&
    read.to == defenseclaw.guardrail.semantic.v1.DataKind.DATA_KIND_PROCESS) &&
  f.data_flows.exists(send,
    send.from_command_id == c.id &&
    send.from == defenseclaw.guardrail.semantic.v1.DataKind.DATA_KIND_PROCESS &&
    send.to == defenseclaw.guardrail.semantic.v1.DataKind.DATA_KIND_NETWORK))

The classifier models the file read and network send as separate edges through the process. Joining both edges to c.id avoids combining unrelated commands.

ActionFacts authoring reference

The only CEL variable is f, typed as defenseclaw.guardrail.semantic.v1.Facts. Field names use protobuf snake case; for example, write f.active_home, not f.activeHome.

ValueTypeImportant fields and use
f.toolstringConnector-reported tool identity. Prefer semantic facts when a rule should be portable.
f.cwdstringTrusted invocation working directory when known.
f.active_homestringTrusted same-host active home when known; never accepted from an untrusted payload claim.
f.parseParseResultstatus, dialect, and bounded issues. CEL runs only after an authoritative complete parse, so partial or ambiguous inputs take the regex fallback before evaluation.
f.commandslist<CommandFact>IDs, parent/pipeline links, program identity, argv, operations, redirects, wrappers, dialect, effect, and completeness.
f.pathslist<PathFact>command_id, access, flavor, raw value, lexical normalized, absolute, and context-resolved resolved.
f.networklist<NetworkFact>command_id, action, scheme, host, port, normalized host, scope, target kind, and prefix length.
f.data_flowslist<DataFlowFact>Source and destination command IDs plus typed from and to data kinds. An endpoint ID of 0 represents a non-command file or network boundary.
f.artifactslist<ArtifactFact>Statically named archive files a command produces or consumes: command_id, role (PRODUCE, CONSUME), kind (ARCHIVE), raw value, normalized, absolute, resolved, and identity, a SHA-256 digest of the normalized path. The file is never opened.
f.archive_lineageslist<ArchiveArtifactLineage>Exact produce-then-consume joins on the same archive identity: produced_by and consumed_by command IDs, normalized path, and authoritative, which is false when either side is unproved.

Command facts

FieldMeaning
id, parent_command_id, pipeline_idDeterministic links between commands, nested wrappers, pipelines, and other fact collections.
kindProcess or shell redirect.
dialectArgv, POSIX, PowerShell, cmd.exe, mixed, or none.
effectExecute, preview, or uncertain. Uncertain analysis takes fallback before CEL; preview facts can support detection but not enforcement evidence.
executableOriginal executable token or path.
programNormalized lowercase program identity; prefer this for portable command matching.
argv, arguments, argv_completeParsed vector plus quote/expansion metadata and a proof that the vector is complete.
operationsTyped behaviors such as read, write, delete, upload, privilege change, or container execution.
redirects, wrappersFile-descriptor targets and wrapper executable/argv chains.

Common enum families

Use fully qualified constants. Numeric enum comparisons and comparisons across different enum families are rejected.

FamilyValues available to rules
OperationKindEXECUTE, READ, WRITE, APPEND, DELETE, COPY, MOVE, LIST, SEARCH, FETCH, UPLOAD, CONNECT, LISTEN, TUNNEL, NETWORK_SCAN, DECODE, PROCESS_KILL, DISK_WRITE, PRIVILEGE, PERMISSION_CHANGE, CONFIG_CHANGE, ACCOUNT_CHANGE, SCHEDULE, CONTAINER_RUN, WORKLOAD_EXEC, NAMESPACE_ENTER, ROOT_CHANGE, ENVIRONMENT_READ, CREDENTIAL_READ, POLICY_BYPASS
PathAccessREAD, WRITE, APPEND, DELETE, EXECUTE, LIST, METADATA, CONNECT
PathFlavorUNKNOWN, POSIX, WINDOWS, DEVICE, REGISTRY
NetworkActionCONNECT, LISTEN, DOWNLOAD, UPLOAD, DNS, TUNNEL, SCAN
NetworkScopeUNKNOWN, LOOPBACK, LINK_LOCAL, PRIVATE, PUBLIC
NetworkTargetKindUNKNOWN, SINGLE_HOST, SINGLE_ADDRESS_CIDR, MULTI_ADDRESS_CIDR, RANGE, LIST, GENERATED
DataKindSTDIN, STDOUT, FILE, NETWORK, PROCESS
CommandEffectEXECUTE, PREVIEW, UNCERTAIN
ParseStatusCOMPLETE, PARTIAL, UNSUPPORTED, INVALID, LIMIT_EXCEEDED, AMBIGUOUS, NOT_APPLICABLE
ArtifactRolePRODUCE, CONSUME
ArtifactKindARCHIVE

Prefix each value with its generated enum name, for example:

CEL enum constant
defenseclaw.guardrail.semantic.v1.PathAccess.PATH_ACCESS_DELETE

The authoritative schema is internal/guardrail/semanticpb/facts.proto.

Validate what you author

Store organization rules in a uniquely named category so omitted bundled components keep the compiled baseline. Run defenseclaw guardrail validate-pack PATH to check schema, required metadata, regex fallback, CEL typing and admission, and cost limits (validate a rule pack). Compilation does not prove every connector projects the facts your expression reads, so run positive and near-miss calls through each target connector in observe mode, and check the tool-surface matrix first. No public CLI injects synthetic ActionFacts into one expression.

For repository contributors: add Go fixtures and use Go 1.26.4, the CI toolchain. Run the gateway suite through the shard runner with an explicit timeout:

go test -timeout 30m ./internal/guardrail/semantic ./internal/guardrail
python3 scripts/go_test_shards.py --package-dir internal/gateway --shard-count 16 -- go test -timeout 30m ./internal/gateway

When the compiled default catalog changes, also run make generate-guardrail-catalog and make check-guardrail-catalog.

Authoring checklist

  • Use a unique category unless replacing a whole bundled category deliberately.
  • Use a unique rule ID and treat it as stable finding metadata.
  • Join related facts with command IDs; never rely on list position.
  • Prefer c.program and typed operations over connector-specific tool names.
  • Treat empty context-derived fields as unknown, not safe.
  • Keep the required fallback scoped to the same security intent as the CEL rule.
  • Validate the complete pack, then test both matches and near misses per connector.
  • Review CEL engine behavior before relying on detection or enforcement semantics.