# Authoring a custom security check

The security catalogue is extensible. Projects with a domain-specific threat model — a perpetuals exchange that needs a "funding-rate sanity" rule, a lending protocol that needs a "health-factor monotonicity" rule, a DAO that needs a "vote-weight cap" rule — should write their own rules and check them in alongside the program source.

This recipe walks through writing a rule end to end. The example rule is `withdraw-amount-cap`: it flags any `withdraw` instruction whose amount argument is not bounded by an explicit constraint.

## Anatomy of a rule

A rule is a TypeScript module that exports two functions and a metadata object.

```ts
import { Rule, IDL, Finding } from "solanatestforge/rule";

export const meta: Rule.Meta = {
  id: "withdraw-amount-cap",
  category: "domain",
  default_severity: "medium",
  description: "Flags withdraw instructions without an explicit amount cap.",
};

export const select: Rule.Select = (idl: IDL) => {
  return idl.instructions.some((ix) => ix.name === "withdraw");
};

export const evaluate: Rule.Evaluate = (idl: IDL): Finding[] => {
  const findings: Finding[] = [];
  for (const ix of idl.instructions) {
    if (ix.name !== "withdraw") continue;
    const amount = ix.args.find((a) => a.name === "amount");
    if (!amount) continue;
    if (!amount.constraints?.some((c) => c.kind === "max")) {
      findings.push({
        rule_id: meta.id,
        severity: meta.default_severity,
        instruction: ix.name,
        account: undefined,
        rationale: "withdraw.amount has no max constraint; large withdraws can bypass policy",
      });
    }
  }
  return findings;
};
```

The split between `select` and `evaluate` is intentional and is described in [The lifecycle of a check](/solanatestforge-docs/part-i-foundations/lifecycle-of-a-check.md): a rule that decides to skip itself is invisible in the report, while a rule that decides to run is visible even if it produces no findings.

## Registering the rule

The rule lives at `.solforge/rules/withdraw-amount-cap.ts` by default. Any module under `.solforge/rules/` that exports the three required symbols is auto-registered when `solforge security` runs; no separate manifest is required.

If a project prefers to keep rules elsewhere — for example, alongside the program source under `programs/<name>/audit/` — the `[security].extra_rules` key in `.solforge.toml` accepts a list of glob patterns:

```toml
[security]
extra_rules = ["programs/*/audit/*.ts"]
```

## Testing the rule

A rule is itself a function and can be unit-tested. The framework ships a small harness that loads a fixture IDL and invokes a rule against it:

```ts
import { test, expect } from "vitest";
import { evaluate } from "./withdraw-amount-cap";
import { loadIDL } from "solanatestforge/test-harness";

test("flags uncapped withdraws", async () => {
  const idl = loadIDL("./fixtures/uncapped-withdraw.json");
  const findings = evaluate(idl);
  expect(findings).toHaveLength(1);
  expect(findings[0].rule_id).toBe("withdraw-amount-cap");
});

test("ignores capped withdraws", async () => {
  const idl = loadIDL("./fixtures/capped-withdraw.json");
  expect(evaluate(idl)).toHaveLength(0);
});
```

Two fixtures, one positive case, one negative case. This is the minimum bar for committing a new rule.

## When to write a rule, when not to

Write a rule when the same finding has been produced by hand twice. The first time something is flagged in a code review is interesting; the second time is a pattern; by the third time the rule should already exist. The catalogue is designed to absorb this kind of accumulated knowledge.

Do not write a rule for a one-off finding. A rule has a maintenance cost — it will produce noise on every run forever — and the cost only pays back when the rule generalises. If the finding is unique to one program, capture it as a reproducer (see [Reproducing mainnet bugs locally](/solanatestforge-docs/part-iii-recipes/reproducing-mainnet-bugs.md)) instead.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://solanatestforge.gitbook.io/solanatestforge-docs/part-iii-recipes/authoring-custom-checks.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
