Skip to content

Write a Module

Maicon Ribeiro Esteves edited this page Jun 20, 2026 · 1 revision

Write a Module

For: a developer writing their first InnerWarden extension. This is the fast path: a working module in about ten minutes.

A module is how you teach InnerWarden something new without forking it. You bundle a small piece of detection or response, a manifest that describes it, a test, and a short README. Then you validate it, enable it, and run the tests. That is the whole loop.

This page is the quickstart. When you want the full manifest schema, every module type, the AI-assisted authoring checklist, or how to publish to the community registry, go to Module Reference.


What you can add

A module bundles one or more of these. You do not need all of them: a module that only adds a detector is valid, and so is one that only adds a skill.

  • A collector reads a data source (a log file, a subprocess, a socket) and emits Event structs. If your "data source" is an external security tool like Falco or Suricata, use the declarative form in Integration Recipes instead, it is faster.
  • A detector watches events and emits an Incident when it sees a pattern (for example, "more than N of this from one IP in 60 seconds").
  • A skill is a response action the agent can run when it decides to act (block an IP, suspend a user). Skills are dry-run by default and never auto-execute until the operator opts in.

For the deeper picture of where these sit in the pipeline, see Architecture.


The ten-minute path

We will build a tiny detector: it fires a High incident when one IP triggers a chosen event kind too many times in a window. Reverse-shell-detector-lite, in spirit.

1. Make the directory

A module lives under modules/<id>/. The id is kebab-case and globally unique.

modules/my-burst/
  module.toml
  src/detectors/my_burst.rs
  tests/integration.rs
  docs/README.md

2. Write the manifest

module.toml is the description InnerWarden reads. This is the minimal version, just enough to validate and enable:

[module]
id          = "my-burst"
name        = "My Burst Detector"
version     = "0.1.0"
description = "Flags an IP that triggers one event kind too many times in a short window"
authors     = ["You <you@example.com>"]
license     = "Apache-2.0"
tier        = "open"
builtin     = false
min_innerwarden = "0.15.0"

[provides]
detectors = ["my-burst"]

The full set of fields (rules, security, preflights, requires) is documented in Module Reference. You do not need them to get a first detector running.

3. Write the detector

A detector is a struct with a process(&mut self, event: &Event) -> Option<Incident> method. It keeps its own state (here, a sliding window per IP) between calls.

use innerwarden_core::{Event, Incident, Severity, EntityRef};
use std::collections::HashMap;

pub struct MyBurst {
    host: String,
    threshold: usize,
    window_seconds: u64,
    state: HashMap<String, Vec<chrono::DateTime<chrono::Utc>>>,
}

impl MyBurst {
    pub fn new(host: impl Into<String>, threshold: usize, window_seconds: u64) -> Self {
        Self { host: host.into(), threshold, window_seconds, state: HashMap::new() }
    }

    pub fn process(&mut self, event: &Event) -> Option<Incident> {
        // Return None for anything you do not handle. Detectors are fail-open.
        if event.kind != "my.relevant_kind" {
            return None;
        }

        let key = event.entities.iter().find_map(|e| e.as_ip())?.to_string();
        let now = event.ts;
        let window = chrono::Duration::seconds(self.window_seconds as i64);

        let entries = self.state.entry(key.clone()).or_default();
        entries.retain(|&ts| now - ts < window);   // drop events outside the window FIRST
        entries.push(now);

        if entries.len() >= self.threshold {
            return Some(Incident {
                ts: now,
                host: self.host.clone(),
                incident_id: format!("my_burst:{}:{}", key, now.to_rfc3339()),
                severity: Severity::High,
                title: format!("Burst from {key}"),
                summary: format!("{} events in {}s from {key}", entries.len(), self.window_seconds),
                evidence: serde_json::json!({ "count": entries.len(), "key": key }),
                recommended_checks: vec!["Check activity from this source IP".into()],
                tags: vec!["my-burst".into()],
                entities: vec![EntityRef::ip(&key)],
            });
        }
        None
    }
}

Two rules that the validator and a reviewer will both check:

  • Return None for unrelated events, never Err. A detector must not crash the sensor.
  • incident_id is detector_name:entity:iso_timestamp and deterministic. Do not use a random UUID, or the same burst would look like a different incident every time.

The exact Event and Incident field names live in Data Formats, and the deeper detector rules (sliding-window correctness, entity types) are in Module Reference.

4. Write at least one test

Every component needs at least one test. The minimum for a detector proves it fires at the threshold and ignores unrelated events:

#[cfg(test)]
mod tests {
    use super::*;

    fn ev(kind: &str, ip: &str) -> Event {
        Event {
            ts: chrono::Utc::now(),
            host: "test-host".into(),
            source: "test".into(),
            kind: kind.into(),
            severity: Severity::Low,
            summary: "test".into(),
            details: serde_json::Value::Null,
            tags: vec![],
            entities: vec![EntityRef::ip(ip)],
        }
    }

    #[test]
    fn fires_at_threshold() {
        let mut d = MyBurst::new("test-host", 3, 300);
        assert!(d.process(&ev("my.relevant_kind", "1.2.3.4")).is_none());
        assert!(d.process(&ev("my.relevant_kind", "1.2.3.4")).is_none());
        assert!(d.process(&ev("my.relevant_kind", "1.2.3.4")).is_some());
    }

    #[test]
    fn ignores_unrelated() {
        let mut d = MyBurst::new("test-host", 3, 300);
        for _ in 0..10 {
            assert!(d.process(&ev("unrelated.kind", "1.2.3.4")).is_none());
        }
    }
}

5. Write a short README

docs/README.md is required and must be at least 300 characters with ## Overview, ## Configuration, and ## Security sections. One honest paragraph each: what it does, what you can tune, and the trade-offs (especially the dry-run guidance for skills). The exact required sections are in Module Reference.

6. Validate, enable, test

These are the real commands. They are all subcommands of innerwarden module. For the full flag list run innerwarden module --help, and see CLI Reference, which owns the command surface.

innerwarden module validate ./modules/my-burst   # structure, manifest, security, docs, tests
innerwarden module enable ./modules/my-burst     # turn it on for this install
make test                                         # your tests run alongside the suite

module validate is your friend: it checks the manifest fields, that anything referenced in your rules is actually declared, that skills have no shell-injection patterns, and that the README and tests exist. Fix what it reports before going further.

If you wrote a module someone else published, innerwarden module install <source> fetches it, and innerwarden module list / innerwarden module search show what is available. Those, and module publish, are covered in Module Reference.


Wiring it into the running sensor

module enable registers an external module so InnerWarden knows about it. If you are developing inside the main repo (a builtin = true module whose code lives in crates/), you also wire the detector into the sensor's detector set and call it in the event loop. The exact registration sites (crates/sensor/src/main.rs, the config struct) are spelled out in Module Reference, and the repo is the source of truth for the current file layout.


A few mistakes that cost time

Mistake Do this instead
Err(...) in a detector on a weird event Return None. Detectors are fail-open.
Random incident_id (UUID) format!("my_burst:{ip}:{}", ts.to_rfc3339()), deterministic.
Building a command with format!("ufw deny {ip}") in a skill Pass each argument separately: .arg("deny").arg("from").arg(ip). No shell strings.
Skipping the dry_run check in a skill First line of execute: if dry_run, return a "would do X" message and touch nothing.
auto_execute = true by default Keep it false. The operator decides when a response is allowed to run on its own.

These last few are about response safety, which is the spine of the whole product. The non-negotiable skill rules and why they exist are in Module Reference and Trust and Safety Invariants.


Next steps

Clone this wiki locally