agentsclimarketplace

Rule authoring

Skill Artifex1/auditor-addon/skills/rule-authoring

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.From its SKILL.md

Install
npx -y skills add Artifex1/auditor-addon --skill rule-authoring

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
  • 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

11.6 KB, as published. Nobody here has run it

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)

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

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.

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:

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:

-- 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

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:

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).

ValueMeaningWhen to use
issue100% accurate, zero FPsSyntactically deterministic — the pattern is either present or not (e.g., missing SPDX, floating pragma)
smellReal antipattern, FPs possibleRule catches a genuine problem but can't see all context (e.g., reentrancy — guards may exist outside AST scope)
pointerLocation for investigationPattern 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:

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:

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

Keep looking

Skills are one crate of 626,194. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.