Skip to content
Back to skills

Rule Authoring

ASecurity

Writing SAiST static analysis rules in Lua — both shipped rules in the auditor-addon repo and custom per-engagement rules in audit workspaces. Use when the user wants to create a new detection rule, add a security check, implement a code smell detector, turn a confirmed finding into a reusable rule, or extend the rule set. Covers rule types (scope/deep/map), the Lua API, language scoping, finding kinds, custom rules, and testing patterns.

  • 4 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added May 26, 2026
developmentpythongobashreactnodeexpresstestingapisecurity

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 16 files and shows the line behind each finding

Scanned May 27, 2026

npx -y skills add Artifex1/auditor-addon --skill rule-authoring --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Rule Authoring?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Rule Authoring
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/artifex1-rule-authoring/badge)](https://www.skillsdirectory.com/skills/artifex1-rule-authoring)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: rule-authoring
description: Writing SAiST static analysis rules in Lua — both shipped rules in the auditor-addon repo and custom per-engagement rules in audit workspaces. Use when the user wants to create a new detection rule, add a security check, implement a code smell detector, turn a confirmed finding into a reusable rule, or extend the rule set. Covers rule types (scope/deep/map), the Lua API, language scoping, finding kinds, custom rules, and testing patterns.
argument-hint: "<rule idea or vulnerability pattern>"
---

# Rule Authoring

Rules are `.lua` files. Two deployment modes:

- **Shipped rules** (`rules/`): bundled with the tool. IDs use language prefixes: `SOL-` (Solidity), `GEN-` (generic/multi-language). Run automatically on `aud run` for applicable languages.
- **Adhoc rules** (any `.lua` file or inline string): per-engagement rules. Load at runtime via `--rule-path=<file>` or `--rule-inline=<lua_code>`.

Both use the exact same Lua interface.

## CLI Binary

This skill requires the `auditor-addon-cli` skill for the `aud` binary. Before running any `aud` command, load that skill to determine the correct binary path.

## Choosing a Rule Type

### Scope Rule (`type = "scope"`)

Walks every AST node across all files. Use for patterns detectable within a single function or file without following call edges. Examples: missing visibility, division before multiplication, dangerous opcode usage.

### Deep Rule (`type = "deep"`)

Same visitor pattern but the walker follows call edges across function boundaries. `ctx.depth` increments at each function transition. Use for patterns spanning multiple functions (e.g., external call followed by state write in caller). Requires `max_depth`.

### Map Rule (`type = "map"`)

Runs once after the full graph is built. No AST traversal — queries the symbol graph directly. Use for cross-function or cross-file reasoning: caller counts, visibility analysis, unused functions, state variable patterns. Defines `check()` instead of `enter()`/`exit()`.

## Rule Structure

### Visitor Rule (scope or deep)

```lua
rule = {
    id = "SOL-002",
    name = "reentrancy",
    severity = "critical",   -- critical | high | medium | low | info
    confidence = "smell",    -- issue | smell | pointer
    type = "deep",           -- "scope" or "deep"
    max_depth = 5,           -- deep only
    description = "Detects state changes after external calls",
    languages = {"solidity"}, -- nil or omitted = all languages
}

-- Module-level state persists across the entire walk (all files)
local seen_external_call = false

-- Per-kind hooks: define `enter_<node_type>` / `exit_<node_type>` for the
-- tree-sitter node kinds you care about. The walker only invokes hooks that
-- exist, so rules only pay the Lua call cost for nodes they actually handle.
function enter_function_definition(node, ctx)
    -- node = { kind, line, file, name, handle }
    -- ctx  = { depth, current_file, current_node }
    seen_external_call = false   -- reset per-function state
end

function enter_call_expression(node, ctx)
    -- Heuristic: any call is treated as potentially external.
    if not seen_external_call then
        seen_external_call = true
    end
end

local function report_write(node, ctx)
    if not seen_external_call then return end
    report.hit({
        file = ctx.current_file,
        line = node.line,
        node_text = ast.text(node.handle) or "",
    })
end

function enter_assignment_expression(node, ctx)           report_write(node, ctx) end
function enter_augmented_assignment_expression(node, ctx) report_write(node, ctx) end

-- Optional: `exit_<kind>` fires bottom-up when leaving a subtree.
-- Optional: `finalize()` fires once after all files have been walked; use for
--           findings that depend on state accumulated across the entire walk.
```

### Map Rule

```lua
rule = {
    id = "SOL-022",
    name = "broad-visibility",
    severity = "info",
    confidence = "pointer",
    type = "map",
    description = "Detects functions with broader visibility than needed",
}

function check()
    local findings = {}
    local functions = graph.get_nodes_by_kind("callable")
    for _, fn in ipairs(functions) do
        local vis = graph.get_property(fn.id, "visibility")
        if vis == "public" then
            local callers = graph.get_incoming_edges(fn.id, "call")
            if #callers == 0 then
                table.insert(findings, {
                    file = fn.file,
                    line = fn.line,
                    node_text = fn.name,
                })
            end
        end
    end
    return findings
end
```

## Hook Lifecycle

### Visitor rules (scope / deep)

1. **`enter_<kind>(node, ctx)`** — called top-down when entering a node of the given tree-sitter kind. Define one per kind you care about (e.g., `enter_function_definition`, `enter_call_expression`).
2. **`exit_<kind>(node, ctx)`** — called bottom-up when leaving a node of that kind. Use for cleanup or patterns that need to know when a subtree is fully visited.
3. **`finalize()`** — called once after all files have been walked. Use for emitting findings that depend on state accumulated across the entire walk (e.g., counting patterns across files, then reporting only if a threshold is met).

All hooks are optional — define only what the rule needs. The walker validates every `enter_*` / `exit_*` function name at load time against the declared languages' tree-sitter vocabulary; typos (e.g., `enter_function_defintion`) raise a rule-load error with a "did you mean" suggestion.

A generic `enter(node, ctx)` / `exit(node, ctx)` is also still accepted and fires on every node — only use it when the rule genuinely needs to react to every kind. Per-kind hooks are dramatically faster because the walker skips nodes with no matching hook entirely.

### Map rules

1. **`check()`** — called once after the full symbol graph is built. Query the graph, return a findings table.

## Lua API

The Lua rule API has three namespaces: `graph.*` (symbol-graph queries), `ast.*` (tree-sitter access), and `report.*` (findings + diagnostics).

**Always run `aud api` to see the current reference.** Every function registered in the Lua runtime appears with its signature and a one-line description. The output is generated from the same registry the runtime uses, so it cannot drift from the actual API.

```bash
aud api
```

Use `aud api` before writing a rule to pick the right function, and when upgrading an existing rule to see what's new.

### Discovering per-language vocabulary

`graph.language_info()` returns the valid argument vocabulary for the current language:

```lua
local info = graph.language_info()
-- info.language    — e.g. "solidity"
-- info.node_kinds  — valid graph.get_nodes_by_kind() arguments
-- info.ref_kinds   — valid ref_kind filters for get_outgoing_edges / get_refs
-- info.properties  — valid graph.get_property() keys (language-specific)
```

Passing an invalid kind or property key emits a diagnostic warning and returns an empty result.

### Graph minimalism

The symbol graph is deliberately small: only **files, containers, and callables** are nodes; only **import, call, inheritance, and using_for** are refs. State variables, modifiers, events, custom errors, structs, and enums are **not** in the graph — they live in the AST.

To query them, use the AST bridge or scope lookups:

```lua
-- Scan a function body for event emits (AST).
-- Graph-query results (fn here) carry `id` but no handle; convert via ast.node(id).
local h = ast.node(fn.id)
for _, emit in ipairs(ast.find(h, "emit_statement")) do ... end

-- Walk an inheritance chain looking for a state variable by name (graph + AST)
local var = graph.find_in_scope(container.id, "balances", "state_variable_declaration")
if graph.exists_in_scope(container.id, "owner", "state_variable_declaration") then ... end
```

`graph.find_in_scope` / `exists_in_scope` walk the C3-linearized MRO of a container and look for a direct AST child of the given tree-sitter type whose `name` field matches.

### AST bridge notes

`node.handle` in `enter()`/`exit()` is an `ast_handle`. Prefer `ast.type()` checks over `ast.text()` — type strings are interned (fast); text copies from Zig to Lua GC (slow for large nodes).

### Reporting

Visitor rules call `report.hit()` inline. Map rules return a findings table from `check()`. Rule metadata (`id`, `name`, `severity`, `description`) is attached automatically — do not repeat it per hit.

Use `report.warn()` to surface non-fatal issues during rule execution (e.g., unexpected graph state, skipped checks). Warnings appear in the `diagnostics` output section, separate from findings.

## Language Scoping

```lua
languages = {"solidity"}           -- single language
languages = {"solidity", "cairo"}  -- multi-language
-- omit or nil                     -- all languages
```

Naming convention:
- **SOL-NNN** — Solidity-specific
- **GEN-NNN** — multi-language (no filter)

Only list languages whose grammar you have verified against the vendor grammars in `vendor/grammars/`.

## Grammar Reference

Compact per-language node-type references live in `skills/rule-authoring/grammars/<lang>.md`. Each lists every named tree-sitter node type with its field names — the same strings used in `ast.type()` and `ast.child_by_field()`. Regenerate after adding a grammar:

```bash
python3 scripts/gen-grammar-refs.py          # all languages
python3 scripts/gen-grammar-refs.py solidity  # one language
```

**When writing a rule**, read the target language's grammar file first to find the correct node type names and field names before writing any `ast.*` calls. Hidden/inline grammar rules (prefixed `_` in tree-sitter) do not appear — their fields surface on the parent node.

## Confidence

Each rule declares `confidence` to signal detection reliability. This is orthogonal to `severity` (which rates how bad the finding is).

| Value | Meaning | When to use |
|---|---|---|
| `issue` | 100% accurate, zero FPs | Syntactically deterministic — the pattern is either present or not (e.g., missing SPDX, floating pragma) |
| `smell` | Real antipattern, FPs possible | Rule catches a genuine problem but can't see all context (e.g., reentrancy — guards may exist outside AST scope) |
| `pointer` | Location for investigation | Pattern flags a spot worth examining but isn't inherently a defect (e.g., broad visibility, double state read) |

Default is `"smell"` if omitted. Bias toward lower confidence — prefer surprise true positives over surprise false positives.

CLI filter: `--confidence=issue,smell` runs only rules at those confidence levels.

**Design principle:** All rules must have a **syntactic** anchor — a structural AST pattern. If detection requires understanding what a variable *means* (name-matching heuristics like "fee", "onBehalf"), it belongs to the agent, not a rule.

## Inline Adhoc Rules (Agent Use)

For quick one-off scans, use `--rule-inline` to avoid writing a file:

```bash
aud run "src/**/*.sol" --rule-inline='
rule = {id="X",name="assembly-use",severity="medium",type="scope",languages={"solidity"}}
function enter_assembly_statement(node, ctx)
  report.hit({file=ctx.current_file, line=node.line, node_text=""})
end'
```

## Testing

Test a shipped or custom rule by running it against a fixture file:

```bash
aud run tests/solidity/fixtures/Vault.sol --rule-path=./rules/SOL-002-reentrancy.lua --json
```

Every rule needs a positive case (the pattern is present and flagged) and a negative case (the safe variant produces no findings). For shipped rules, add Zig integration tests in `tests/<lang>/integration_test.zig`.

### Adding a Shipped Rule

1. Add the `.lua` file to `rules/` with a standard ID prefix
2. Register it in `src/rules/shipped.zig`
3. Add test cases in `tests/<lang>/integration_test.zig`

Files in this skill

  • SKILL.md11.6 KB
  • grammars/cairo.md10 KB
  • grammars/compact.md8.2 KB
  • grammars/cpp.md30 KB
  • grammars/go.md8.2 KB
  • grammars/java.md13.6 KB
  • grammars/javascript.md10.1 KB
  • grammars/masm.md5.9 KB
  • grammars/move-sui.md18.7 KB
  • grammars/noir.md21.3 KB
  • grammars/python.md9.8 KB
  • grammars/rust.md15.7 KB
  • grammars/solidity.md11.4 KB
  • grammars/tolk.md27.6 KB
  • grammars/tsx.md18.7 KB
  • grammars/typescript.md17.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.