Skip to content

Module Reference

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

Module Reference

For: module authors going deeper than the quickstart: the full manifest schema, every module type, the safety rules, the AI-assisted authoring checklist, and how to publish to the registry.

If you have not built a module yet, start with Write a Module, which gets a detector running in about ten minutes. This page is the reference you reach for once you are past that.


What a module is

A module is a self-contained vertical solution for one security problem. It bundles any subset of:

  • Collector: reads a data source and emits Event structs into the sensor pipeline.
  • Detector: analyses events and emits Incident structs when it sees a pattern.
  • Skill: runs a response action when the agent decides to act.
  • Rules: default mappings from a detector to a skill (the operator can override these).
  • Config examples: copy-pasteable TOML snippets.
  • Tests: at least one per component.
  • Documentation: docs/README.md explaining what the module does and when to use it.

You do not need all of these. A skill-only module is valid; a detector-only module is valid.

Built-in vs external modules

Type Where the code lives When
Built-in crates/sensor/ or crates/agent/ The module is part of the main repo (existing, or an accepted PR).
External modules/<id>/src/ A new module in development, not yet merged into the crates.

External modules are developed in modules/<id>/src/ and wired into the appropriate crate when they are ready to merge. There is no dynamic plugin loader: enabling an external module registers it; merging a built-in module moves the code into a crate.

The current source-tree layout (which crate, which directory, which registration site) changes between releases. Treat the repository as the source of truth rather than a copied file map. The repo ships existing modules you can read as worked examples (look under modules/), and an external module under development should mirror their structure.


Directory structure

modules/
  my-module/
    module.toml               # manifest (required)
    src/
      collectors/my_collector.rs   # optional
      detectors/my_detector.rs     # optional
      skills/my_skill.rs           # optional
    config/
      sensor.example.toml          # copy-pasteable sensor snippet
      agent.example.toml           # copy-pasteable agent snippet
    tests/integration.rs           # required, at least one test
    docs/README.md                 # required

module.toml schema

[module]
id          = "my-module"          # kebab-case, globally unique
name        = "My Module"          # human-readable
version     = "0.1.0"              # semver
description = "One sentence: what problem this solves and how"
authors     = ["Name <email>"]
license     = "Apache-2.0"
tier        = "open"               # open | premium
builtin     = false                # true if code lives in crates/ already
min_innerwarden = "0.15.0"

# What this module provides. IDs must match struct/file names.
[provides]
collectors = ["my-collector"]      # omit the section if not providing collectors
detectors  = ["my-detector"]       # omit if not providing detectors
skills     = ["my-skill"]          # omit if not providing skills

# Event sources this module needs to function.
[requires]
event_sources = ["auth_log", "journald.sshd"]

# Default rules linking a detector to a skill.
# The operator can override these in agent.toml.
[[rules]]
detector       = "my-detector"
skill          = "my-skill"
min_confidence = 0.8
auto_execute   = false             # MUST be false by default (safety rule)

# System commands this module's skills are allowed to run.
[security]
allowed_commands        = ["/usr/sbin/example-tool"]
require_sudo_validation = true     # if skills write sudoers files
forbid_shell_expansion  = true     # always true

# Prerequisites that must hold before the module can activate.
[[preflights]]
kind   = "binary_exists"           # binary_exists | directory_exists | user_exists
value  = "/usr/sbin/example-tool"
reason = "required for skill execution"

tier is open or premium. auto_execute defaults to false and the operator opts in; this is a safety invariant, not a style choice (see Trust and Safety Invariants). Config keys your module reads should be documented in Configuration, which owns the TOML reference.


Implementing a collector

Collectors are async fn run() methods that tail a file, subprocess, or socket and send Event structs over an mpsc::Sender.

use innerwarden_core::{Event, Severity, EntityRef};
use tokio::sync::mpsc;
use anyhow::Result;

pub struct MyCollector {
    pub path: String,
}

impl MyCollector {
    pub async fn run(self, tx: mpsc::Sender<Event>) -> Result<()> {
        loop {
            let event = Event {
                ts: chrono::Utc::now(),
                host: "my-host".into(),
                source: "my_collector".into(),   // snake_case, matches the config key
                kind: "my.event_type".into(),     // dot-separated: domain.event_type
                severity: Severity::Low,
                summary: "Something happened".into(),
                details: serde_json::json!({ "key": "value" }),
                tags: vec![],
                entities: vec![EntityRef::ip("1.2.3.4")],
            };

            if tx.send(event).await.is_err() {
                break;   // pipeline closed, exit cleanly
            }
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        }
        Ok(())
    }
}

Collector rules

  1. Never use ? at the top level of run(). Log errors with tracing::warn! and keep going. Collectors are fail-open: an I/O hiccup must not crash the sensor.
  2. Use spawn_blocking for synchronous file I/O inside async tasks.
  3. source is a stable snake_case string that matches the config key.
  4. kind is dot-separated: domain.event_type, for example ssh.login_failed.

The full Event schema (every field, the EntityRef constructors, the severity enum) lives in Data Formats.

If your data source is an external security tool that already does its own detection (Falco, Suricata, Wazuh, osquery), do not hand-write a collector. Describe it once in a declarative recipe and let the recipe drive the implementation: see Integration Recipes.


Implementing a detector

Detectors are structs with process(&mut self, event: &Event) -> Option<Incident>. They keep internal state (sliding windows, counters) between calls. The quickstart in Write a Module has a complete worked detector; the rules below are the contract.

Detector rules

  1. incident_id format is detector_name:entity:iso_timestamp and deterministic. Never a random UUID. Two identical bursts must produce a stable, groupable id.
  2. Always attach entities: EntityRef::ip() for IPs, EntityRef::user() for users. Entities are how the correlation engine pivots one event into an attack chain.
  3. The detector name in incident_id must match the id you declared in [provides].detectors.
  4. Return None for unrelated event kinds. A detector that returns Err or panics on unexpected input is a bug, not a safety feature.
  5. Sliding-window state is in-memory and resets on restart. That is acceptable by design: detectors are stateless across restarts.

What detectors do across the whole product, and the cross-layer correlation that stitches their incidents into chains, is described in What It Detects.


Implementing a skill

Skills implement the ResponseSkill trait. A skill is the only part of a module that can change the system, so the rules here are strict.

use crate::skills::{ResponseSkill, SkillContext, SkillResult, SkillTier};
use std::pin::Pin;
use std::future::Future;

pub struct MySkill;

impl ResponseSkill for MySkill {
    fn id(&self) -> &'static str { "my-skill" }
    fn name(&self) -> &'static str { "My Skill" }
    fn description(&self) -> &'static str {
        "Brief description the AI reads to decide whether to use this skill"
    }
    fn tier(&self) -> SkillTier { SkillTier::Open }

    fn applicable_to(&self) -> &'static [&'static str] {
        &["my-detector"]   // incident types this applies to; &[] means any
    }

    fn execute<'a>(
        &'a self,
        ctx: &'a SkillContext,
        dry_run: bool,
    ) -> Pin<Box<dyn Future<Output = SkillResult> + Send + 'a>> {
        Box::pin(async move {
            let target = match &ctx.target_ip {
                Some(ip) => ip.clone(),
                None => return SkillResult { success: false, message: "no target_ip".into() },
            };

            if dry_run {
                return SkillResult {
                    success: true,
                    message: format!("DRY RUN: would run my-skill against {target}"),
                };
            }

            let output = tokio::process::Command::new("/usr/sbin/my-tool")
                .arg("action")
                .arg(&target)   // separate .arg() calls, never format! into one string
                .output()
                .await;

            match output {
                Ok(o) if o.status.success() =>
                    SkillResult { success: true, message: format!("applied to {target}") },
                Ok(o) =>
                    SkillResult { success: false, message: format!("my-tool failed: {}", String::from_utf8_lossy(&o.stderr)) },
                Err(e) =>
                    SkillResult { success: false, message: format!("failed to run my-tool: {e}") },
            }
        })
    }
}

Skill safety rules (non-negotiable)

  1. Always check dry_run first and return a descriptive "would do X" message without executing anything.
  2. Never run a command that is not in [security].allowed_commands.
  3. Never use format!() to build command arguments. Pass each argument as a separate .arg() call.
  4. Never use shell expansion. Do not route arguments through sh -c.
  5. Never write files outside ctx.data_dir except to paths declared in the manifest.
  6. Never make outbound network calls from a skill. Use the agent's notification path for anything outbound.
  7. No unsafe blocks without a // SAFETY: comment and explicit PR review.

These rules are why a poisoned detector or a tricked AI decision cannot turn a skill into a shell-injection primitive. The proof floor that backs them up (a typestate gate that refuses an allowlist-driven block-bypass without a valid token) is described in Trust and Safety Invariants.


Tests

Every component needs at least one test. The quickstart shows the minimum detector test. The minimum skill test proves a dry run changes nothing:

#[tokio::test]
async fn skill_dry_run_is_noop() {
    let skill = MySkill;
    let ctx = make_test_context("1.2.3.4");
    let result = skill.execute(&ctx, true).await;
    assert!(result.success);
    assert!(result.message.contains("DRY RUN"));
    // Assert no system state was modified.
}

Run the whole suite with make test. Your module's tests run alongside the existing ones, and both must pass.


Documentation

docs/README.md is required, at least 300 characters, and must contain these sections:

# module-id

One sentence: what problem this solves.

## Overview
What it does, which components it includes, how they interact.

## Configuration
Table of tunable parameters with defaults and meaning.

## Security
Risks, trade-offs, and how to use it safely (especially dry-run guidance).

## Source code
Links to the relevant files in crates/.

Config examples must be copy-pasteable and correct.


Validation

Before opening a PR, run the validator. The command and its flags are owned by CLI Reference; here is what it checks:

innerwarden module validate ./modules/my-module
innerwarden module validate --strict ./modules/my-module   # what reviewers run
Check What it verifies
Structure module.toml, docs/README.md, and tests/ with at least one .rs file exist.
Manifest Required fields present; id is kebab-case; version is semver; tier is open or premium.
Rules detector and skill in [[rules]] are declared in [provides].
Confidence min_confidence is between 0.0 and 1.0.
auto_execute Must be false (or explicitly true with a justification).
Security No format!() in command args, no sh -c patterns, every skill checks dry_run, unsafe blocks have a // SAFETY: comment.
Docs README has the required sections and minimum length.
Tests At least one #[test] or #[tokio::test] in tests/.

Validation failures block a merge. Warnings are reviewed and may be accepted.


Authoring a module with an AI assistant

AI coding tools (Claude Code, Cursor, Copilot, Codex) generate good modules when they have the right context, and dangerous ones when they do not. Without guidance they tend to invent their own trait instead of implementing ResponseSkill, build a command with format!("ufw deny {ip}") (a shell-injection hole), skip the dry_run check, or write a detector that crashes on bad input. With the right context they produce correct, production-quality modules quickly.

Give the assistant the right context

  1. This page and Write a Module: the rules and patterns.
  2. The project conventions (the repo's CLAUDE.md / AGENTS.md): architecture and house style.
  3. An existing module as a worked example (read one under modules/ in the repo).
  4. The real type definitions: the Event and Incident structs and the ResponseSkill trait. Their field names are in Data Formats; the live definitions are in the repo.

Claude Code reads the project's CLAUDE.md automatically when run from the repo root. For other tools, paste the conventions file and this page at the start of the conversation.

A prompt that works

Create an InnerWarden module "<module-id>" that solves this problem:

<the security problem in 1-3 sentences>

It should:
- Collect from: <data source>
- Detect: <the pattern: thresholds, windows, conditions>
- Respond with: <the response action, or "reuse an existing skill">

Produce module.toml, the collector/detector/skill source as needed,
config/sensor.example.toml + config/agent.example.toml, tests with at least
one test per component, and docs/README.md. Use an existing module in modules/
as the structural reference. Follow every security rule in Module Reference:
dry_run first, allowed_commands, no format! in args, no sh -c, deterministic
incident_id, return None for unrelated events.

Review the generated code before you accept it

Security

  • The skill checks dry_run before any system call.
  • Command::new() uses a literal path, not a variable or a format string.
  • Each .arg() passes a single validated value; no sh -c, no shell string.
  • The skill's command is listed in [security].allowed_commands.
  • No unsafe blocks.

Correctness

  • The detector returns None for unrelated event kinds.
  • The sliding window removes old entries before checking the threshold.
  • The collector's source matches the config key.
  • incident_id is detector_name:entity:timestamp and deterministic.
  • Entities use EntityRef::ip() / EntityRef::user().
  • applicable_to() lists the correct detector names.
  • [[rules]] references ids that exist in [provides].

Registration (what AI most often forgets, for a built-in module)

  • A collector is spawned in the sensor's main wiring.
  • A detector is added to the detector set and called in the event loop.
  • A skill is registered in the skill registry.
  • A config struct is added if you introduced a new detector or collector.

Then validate (innerwarden module validate) and run make test.


Publishing and the registry

The path from a working module to one other people can install:

  1. Validate with innerwarden module validate --strict ./modules/<id> and run make test. Everything must pass.
  2. Open a PR to the main repository. Use the PR template's module-submission section. If you used an AI to generate it, include the prompt in the PR description so reviewers can verify it quickly. The module-validation CI workflow runs automatically on any PR that touches modules/.
  3. Reviewers run the strict validator and check the security and correctness items above. Failures block the merge.
  4. After merge, maintainers add the module to the registry so anyone can install it:
innerwarden module install <module-id>
innerwarden module search <term>        # find available modules
innerwarden module list                 # what is installed / enabled
innerwarden module publish              # prepare a module for the registry

These commands and their flags are owned by CLI Reference.

What makes a module accepted

  • It solves a real, specific security problem.
  • It passes innerwarden module validate --strict.
  • It has at least one meaningful test per component.
  • auto_execute = false by default. The operator opts in to autonomous response.
  • [security].allowed_commands is minimal and accurate.
  • docs/README.md honestly explains what it does, what it needs, and the trade-offs.

Branch naming

PR type Branch prefix
New module module/<id>
Fix an existing module fix/module-<id>-<short-desc>
Module docs update docs/module-<id>

Common mistakes

Mistake Correct pattern
Command::new("sudo").arg(format!("ufw deny {ip}")) Command::new("/usr/sbin/ufw").arg("deny").arg("from").arg(ip)
Err(...) in a detector on a bad event Return None. Detectors are fail-open.
Forgetting the dry_run check First line: if dry_run { return SkillResult { ... } }.
incident_id = Uuid::new_v4() format!("my_detector:{ip}:{}", ts.to_rfc3339()), deterministic.
applicable_to: &[] for a detector-specific skill List the specific detector names: &["my-detector"].
auto_execute = true in [[rules]] Keep it false. The operator decides.

See also

Clone this wiki locally