Write and test custom rules
Use the authoring commands to validate your own YAML, test explicit cases and estimate a candidate rule's impact on local Claude Code transcripts. They use the same validator and executor pipeline as NebGuard hooks. They do not install rules or change your active hook configuration.
Start with an example
nebguard rules new --executor pattern > candidate.yaml
nebguard rules lint candidate.yaml --json
new prints an existing project starter as YAML. Starters are available for pattern, rate-limiter, state-gate, content-analysis, session-monitor and confidence-gate. A registered executor without a starter returns an explicit unavailable result. A valid starter can still require context that offline testing does not support.
lint accepts one or more files and reports the shared validator's result in input order. Only supplied CUSTOM- rules are considered; these commands do not expose or load the installed rule inventory. Candidate settings are combined with defaults, not with your ambient configuration.
Inputs are limited to 16 current-user regular files, 256 KiB each, 1 MiB total and 64 declared custom-rule entries, including inactive candidates. Files and their permissions remain unchanged.
Test one command
This small candidate blocks a synthetic command name:
custom_rules:
- id: CUSTOM-SAMPLE-001
name: Sample command guard
description: Block the synthetic sample command.
executor: pattern
hook: pre_tool_use
decision: block
bypass: allowed
severity: high
config:
pattern: sample-danger
reason: Use the safe sample command.
l5_directive: Replace the synthetic unsafe command.
nebguard rules test --rules candidate.yaml --tool Bash --input 'sample-danger' --json
nebguard rules test --rules candidate.yaml --tool Bash --input 'echo safe' --json
nebguard rules test --rules candidate.yaml --tool Bash --input 'sample-danger # REASON: approved sample' --json
The command string is data. The authoring command never executes it. A one-shot result reports the actual decision; a blocking decision alone does not make the test command fail.
Check expected results and ordered state
Use a JSON case file to assert decisions. Cases in one session share a fresh detached state store and run in order. Different sessions start independently. No live session ID, transcript path or caller-provided expanded script grants access to existing session state.
{
"schemaVersion": 1,
"sessions": [
{
"name": "sample",
"cases": [
{
"name": "blocked",
"hook": "pre_tool_use",
"input": {
"tool_name": "Bash",
"tool_input": {
"command": "sample-danger"
}
},
"expectedDecision": "block"
},
{
"name": "allowed",
"hook": "pre_tool_use",
"input": {
"tool_name": "Bash",
"tool_input": {
"command": "echo safe"
}
},
"expectedDecision": "allow"
}
]
}
]
}
nebguard rules test --rules candidate.yaml --cases cases.json --json
Repeat --rules for additional candidate files. Use --execution-context human or agent to select activation context explicitly; the default is human. The shared audience and execution-context selectors still apply. Expected decisions are allow, block, guide, bypass or context. Unknown fields, invalid hooks and oversized inputs are rejected before evaluation.
Case files are limited to 8 MiB and 1,000 cases. Each case's hook input is limited to 1 MiB. Session and case names are unique within their respective lists and limited to 128 bytes. Time-window rules use the actual invocation clock; historical timestamps do not recreate historical state.
Supply script and URL contents
Script expansion uses explicit source declarations. It never reads today's script or fetches a URL to fill missing case context. For example, add this sources array to a case whose Bash command is bash declared.sh:
{
"sources": [
{
"kind": "script",
"aliases": [
"declared.sh"
],
"identity": "/supplied/declared.sh",
"content": "sample-danger"
}
]
}
For fetch-scan, declare an exact URL with kind: "url", url and content. To test a declared source failure, use outcome: "missing" or "unreadable" instead of content. An undeclared required source is unsupported context, not a simulated fetch failure. Each case allows at most 32 source entries, 256 KiB per entry and 1 MiB total supplied content. Existing expansion limits can be stricter.
The shared enforcement mode, soft-fail behavior, bypass rules and script-expansion policy still apply. Script-expansion decisions are identified as pre-executor outcomes, separately from candidate fires.
Estimate impact on recorded sessions
nebguard rules dry-run --rules candidate.yaml /absolute/claude/transcripts --host claude-code --json
Without explicit paths, the selected supported host's transcript root is used. Claude Code extraction is supported; Codex CLI and other adapters without transcript extraction return an explicit unsupported-host result.
Dry-run supports only pre_tool_use pattern and content-analysis candidates with supported configuration. Every active candidate is evaluated independently against the retained sample, so one candidate cannot hide another's matches. Inactive candidates are reported with their activation reason. Counts distinguish candidate fires, pre-executor decisions, evaluated events and events missing required context.
Results describe sanitized sampled impact, not exhaustive analysis or exact raw-input parity. Missing historical script contents are counted as unevaluable and produce partial coverage. Input permissions are preserved, and directory symlinks are not followed.
The bounded corpus is stored at ~/.nebcore/nebguard/authoring/corpus.json, under the configured NebGuard home. Existing events are retained and deduplicated; reaching capacity reports partial coverage rather than evicting old entries silently. Concurrent publication returns busy.
| Bound | Maximum |
|---|---|
| Directory and file entries per invocation | 10,000 |
| Total input bytes per invocation | 256 MiB |
| Transcript line | 64 MiB |
| Sampled events per file | 25 |
| Retained unique events | 2,500 |
| Retained source records | 10,000 |
| Encoded corpus snapshot | 16 MiB |
A partial report names the reached limit or unavailable context. Repeating an invocation does not turn a sampled file into proof of complete analysis.
Context limits and privacy
Case testing supports supplied-input rules and the explicitly supported ordered-state executor modes. It refuses Git-dependent patterns, commit-message state gates, Git/filesystem scope executors and session-monitor modes that require live environment or session evidence. Dry-run refuses all session-flow executors before ingestion. File paths and working-directory strings are matcher inputs, not permission to inspect those locations.
The commands make no network requests and emit no telemetry from candidates, cases or transcripts. Lint, test and new create no corpus. Test state is temporary and removed after evaluation; dry-run writes only its private authoring corpus and temporary evaluation state. Existing hook audit behavior remains unchanged.
Shared sanitization removes recognized sensitive patterns and home-path prefixes before corpus persistence. This is best-effort minimization, not a guarantee that every arbitrary secret is recognized. Keep source transcripts and generated corpora private, and inspect samples before sharing them.
Output and exit status
--json on lint, test and dry-run emits one object with schemaVersion, status, errors and results. Arrays are present even when empty. Human output renders the same results. new emits YAML on success.
| Exit | Meaning |
|---|---|
0 | Successful validation, completed one-shot evaluation, all expected cases matched, or complete dry-run |
1 | Lint rejected input, a case expectation differed, or dry-run coverage is partial |
2 | Invalid test/dry-run arguments or input, unsupported context or host, I/O/publication failure, unavailable starter, or output/cleanup failure |
To compare a case with a real hook, match the candidate, settings, activation context, raw input, source bytes and ordered state. A sanitized transcript sample has different inputs and makes a different, narrower claim.