-
-
Notifications
You must be signed in to change notification settings - Fork 30
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.
A module is a self-contained vertical solution for one security problem. It bundles any subset of:
-
Collector: reads a data source and emits
Eventstructs into the sensor pipeline. -
Detector: analyses events and emits
Incidentstructs 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.mdexplaining 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.
| 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.
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]
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.
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(())
}
}-
Never use
?at the top level ofrun(). Log errors withtracing::warn!and keep going. Collectors are fail-open: an I/O hiccup must not crash the sensor. -
Use
spawn_blockingfor synchronous file I/O inside async tasks. -
sourceis a stable snake_case string that matches the config key. -
kindis dot-separated:domain.event_type, for examplessh.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.
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.
-
incident_idformat isdetector_name:entity:iso_timestampand deterministic. Never a random UUID. Two identical bursts must produce a stable, groupable id. -
Always attach entities:
EntityRef::ip()for IPs,EntityRef::user()for users. Entities are how the correlation engine pivots one event into an attack chain. -
The detector name in
incident_idmust match the id you declared in[provides].detectors. -
Return
Nonefor unrelated event kinds. A detector that returnsError panics on unexpected input is a bug, not a safety feature. - 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.
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}") },
}
})
}
}-
Always check
dry_runfirst and return a descriptive "would do X" message without executing anything. - Never run a command that is not in
[security].allowed_commands. -
Never use
format!()to build command arguments. Pass each argument as a separate.arg()call. -
Never use shell expansion. Do not route arguments through
sh -c. -
Never write files outside
ctx.data_direxcept to paths declared in the manifest. - Never make outbound network calls from a skill. Use the agent's notification path for anything outbound.
-
No
unsafeblocks 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.
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.
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.
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.
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.
- This page and Write a Module: the rules and patterns.
-
The project conventions (the repo's
CLAUDE.md/AGENTS.md): architecture and house style. -
An existing module as a worked example (read one under
modules/in the repo). -
The real type definitions: the
EventandIncidentstructs and theResponseSkilltrait. 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.
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.
Security
- The skill checks
dry_runbefore any system call. -
Command::new()uses a literal path, not a variable or a format string. - Each
.arg()passes a single validated value; nosh -c, no shell string. - The skill's command is listed in
[security].allowed_commands. - No
unsafeblocks.
Correctness
- The detector returns
Nonefor unrelated event kinds. - The sliding window removes old entries before checking the threshold.
- The collector's
sourcematches the config key. -
incident_idisdetector_name:entity:timestampand 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.
The path from a working module to one other people can install:
-
Validate with
innerwarden module validate --strict ./modules/<id>and runmake test. Everything must pass. -
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/. - Reviewers run the strict validator and check the security and correctness items above. Failures block the merge.
- 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 registryThese commands and their flags are owned by CLI Reference.
- It solves a real, specific security problem.
- It passes
innerwarden module validate --strict. - It has at least one meaningful test per component.
-
auto_execute = falseby default. The operator opts in to autonomous response. -
[security].allowed_commandsis minimal and accurate. -
docs/README.mdhonestly explains what it does, what it needs, and the trade-offs.
| PR type | Branch prefix |
|---|---|
| New module | module/<id> |
| Fix an existing module | fix/module-<id>-<short-desc> |
| Module docs update | docs/module-<id> |
| 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. |
- The ten-minute quickstart: Write a Module
- Wrapping an external tool as a collector: Integration Recipes
- Every command and flag used here: CLI Reference
-
Event/Incidentfield names: Data Formats - Config keys your module reads or writes: Configuration
- The safety guarantees a module must not break: Trust and Safety Invariants