# Writing Custom Checks

NoiseGate's check system is built on [pluggy](https://pluggy.readthedocs.io/). You can add your own checks — or override built-in ones — by writing a plugin and registering it via a Python entry point.

---

## How checks work

When `noisegate triage` runs, it:

1. Loads your policy file (`~/.config/noisegate/policy.yaml`) to get the list of checks to run.
2. For each check, calls the registered plugin that handles that check ID.
3. Collects the results, derives a verdict (`PASS` / `WARN` / `FAIL`), and displays output.

**Plugins are called in reverse registration order** (LIFO), and the first plugin to return a non-`None` result wins. This means a custom plugin can **override** a built-in check by handling the same check ID.

---

## Example: a simple custom check

```python
# my_noisegate_plugin.py

from noisegate.checks import hookimpl
from noisegate.models import Check, CheckResult, Program, Report


class MyChecks:
    @hookimpl
    def run_check(
        self,
        check: Check,
        report: Report,
        program: Program,
    ) -> CheckResult | None:
        if check.id != "my_org.has_cve_reference":
            return None  # not our check — let the next plugin handle it

        passed = "CVE-" in report.raw_text
        return CheckResult(
            check_id=check.id,
            passed=passed,
            reason="CVE reference found." if passed else "No CVE reference in report.",
        )
```

Return `None` for any check ID your plugin doesn't handle; NoiseGate will try the next plugin. Return a `CheckResult` to claim the check.

---

## Registering the plugin

In your package's `pyproject.toml`, register your plugin class under the `noisegate` entry point group:

```toml
[project.entry-points.noisegate]
my_plugin = "my_package.plugin:MyChecks"
```

After installing your package (`pip install -e .`), NoiseGate will automatically discover and load it.

---

## Adding the check to your policy

Add the check ID to `~/.config/noisegate/policy.yaml`:

```yaml
checks:
  - id: asset_in_scope
    hard_fail: true
  - id: finding_ineligible
    hard_fail: true
  - id: required_sections_present
  - id: reproduction_quality
  - id: impact_quality
  - id: my_org.has_cve_reference   # your custom check
```

Use namespaced IDs (e.g. `my_org.check_name`) to avoid collisions with built-in check IDs.

---

## Passing configuration to a check

The `Check` model has a `config` field — a free-form dict populated from your policy YAML. Use it to parameterize checks without hardcoding values:

```yaml
# policy.yaml
checks:
  - id: my_org.keyword_check
    config:
      required_keywords: ["CVSS", "CWE"]
      case_sensitive: false
```

```python
@hookimpl
def run_check(self, check: Check, report: Report, program: Program) -> CheckResult | None:
    if check.id != "my_org.keyword_check":
        return None

    keywords = check.config.get("required_keywords", [])
    case_sensitive = check.config.get("case_sensitive", True)
    text = report.raw_text if case_sensitive else report.raw_text.lower()

    missing = [k for k in keywords if (k if case_sensitive else k.lower()) not in text]
    passed = not missing
    reason = "All required keywords present." if passed else f"Missing keywords: {', '.join(missing)}."
    return CheckResult(check_id=check.id, passed=passed, reason=reason)
```

---

## Using `hard_fail`

Any check can be set as `hard_fail: true` in the policy. A failed `hard_fail` check produces a `FAIL` verdict immediately, regardless of other check results. This is controlled by the policy, not the plugin — your plugin only needs to return `passed: true/false`.

---

## Available context

Your `run_check` hook receives:

| Parameter | Type | Contents |
|-----------|------|----------|
| `check` | `Check` | The check being evaluated: `id`, `hard_fail`, `config` |
| `report` | `Report` | The bug report: `raw_text`, `source_path`, `char_count` |
| `program` | `Program` | The bug bounty program: `name`, `platform`, `assets`, `ineligible_findings`, `required_report_sections`, `notes` |

All types are importable from `noisegate.models`.

---

## Overriding a built-in check

To replace a built-in check (e.g. `asset_in_scope`) with your own logic, handle that check ID in your plugin:

```python
@hookimpl
def run_check(self, check, report, program) -> CheckResult | None:
    if check.id != "asset_in_scope":
        return None
    # your custom scope logic here
    passed = check_scope(report, program)
    return CheckResult(check_id=check.id, passed=passed, reason="...")
```

Because your plugin is loaded after the built-ins, it is called first (LIFO). Returning a non-`None` result prevents the built-in from running.

---

## Calling the LLM from a custom check

Custom checks can make their own LLM calls if needed. Use pydantic-ai directly, or call `noisegate.llm.run_checks` with a single-element list:

```python
from noisegate.llm import run_checks

@hookimpl
def run_check(self, check, report, program) -> CheckResult | None:
    if check.id != "my_org.llm_check":
        return None

    # Run a single LLM check
    result = run_checks([check], report, program, model="anthropic:claude-sonnet-4-6")
    return result.checks[0] if result.checks else None
```

Note: token counts for custom LLM calls are not tracked in the triage output. The `prompt_tokens` / `completion_tokens` fields reflect only the built-in batch call.
