Skip to content

Writing a Module

Action modules are self-registering units that perform a specific action against a target service. They live in modules/<name>/ and register at init() time via sdk/module.Register(). Two campaign scenarios instead use sdk/campaign, and protoscan is a discovery engine. Config, MCP, and A2A enumeration use collector-specific paths; their module registry entries are metadata-only and do not implement or dispatch Enumerator.

Action Interfaces

Choose the interface that matches your module's purpose:

Interface Action Contract Mutating?
Fingerprinter fingerprint Probe a target, identify the service kind/version/auth No
Scanner scan Expand a CIDR / range / discovery seed into concrete Targets (feeds Fingerprinters) No
Enumerator enumerate Reserved contract for inspecting a single Target; the current CLI does not dispatch it No
Looter loot Extract secrets/state read-only (GET/HEAD; idempotent search/lookup POSTs allowed with a get_only guard) No
Extractor extract Analyze a specific operator-supplied resource by reference No
Poisoner poison Inject content into upstream artifacts Yes — requires Reverter
Implanter implant Plant persistent backdoors in target config Yes — requires Reverter

Reverter (sdk/action/reverter.go) is not an action of its own — it is a compile-time-mandatory super-interface every Poisoner and Implanter embeds, so every destructive module ships an explicit agenthound revert recovery path. Runtime restoration must be verified and may still be blocked by provider policy, conflicts, deletion, or loss of access.

All interfaces are defined in sdk/action/. Every module also implements sdk/module.Module:

type Module interface {
    ID() string            // dotted lowercase: "ollama.fingerprint"
    Action() action.Action // action.Fingerprint, action.Loot, etc.
    Target() string        // service kind: "ollama", "litellm", "mcp"
    Description() string   // one-line summary
    Version() string       // semver
    IsDestructive() bool   // true for Poisoner/Implanter
}

Step-by-Step: Creating a Fingerprinter

We'll use modules/ollamafp/ as the worked example.

1. Create the directory

modules/yourservice/
    register.go
    fingerprinter.go
    fingerprinter_test.go

2. Implement the action interface

fingerprinter.go:

package yourservicefp

import (
    "context"
    "errors"
    "github.com/adithyan-ak/agenthound/sdk/action"
    "github.com/adithyan-ak/agenthound/sdk/rules"
)

type Fingerprinter struct {
    rule *rules.FingerprintRule
}

func New() (*Fingerprinter, error) {
    all, err := rules.LoadFingerprints()
    if err != nil {
        return nil, err
    }
    for _, r := range all {
        if r.ServiceKind == "yourservice" {
            rule := r
            return &Fingerprinter{rule: &rule}, nil
        }
    }
    return nil, errors.New("yourservice fingerprint rule not found")
}

func (f *Fingerprinter) Fingerprint(ctx context.Context, t action.Target) (*action.FingerprintResult, error) {
    // Replace this no-match skeleton with the service-specific probe and
    // IngestData construction used by modules/ollamafp.
    return &action.FingerprintResult{Matched: false}, nil
}

var _ action.Fingerprinter = (*Fingerprinter)(nil)

Key points from the ollamafp implementation: - Load the fingerprint rule from sdk/rules/builtin/fingerprints/ by service_kind - Use rules.RunFingerprint() to dispatch the HTTP probe and matchers - Compute deterministic node ID via ingest.ComputeNodeID("YourKind", endpoint) - Return IngestData with multi-label node (e.g., ["YourKind", "AIService"])

3. Write register.go

package yourservicefp

import (
    "log/slog"
    "github.com/adithyan-ak/agenthound/sdk/action"
    "github.com/adithyan-ak/agenthound/sdk/module"
)

func init() {
    f, err := New()
    if err != nil {
        slog.Warn("yourservice fingerprinter init failed", "error", err)
        module.Register(&disabledFingerprinter{})
        return
    }
    module.Register(f)
}

func (*Fingerprinter) ID() string            { return "yourservice.fingerprint" }
func (*Fingerprinter) Action() action.Action { return action.Fingerprint }
func (*Fingerprinter) Target() string        { return "yourservice" }
func (*Fingerprinter) Description() string   { return "Identify YourService by ..." }
func (*Fingerprinter) Version() string       { return "0.1.0" }
func (*Fingerprinter) IsDestructive() bool   { return false }

// disabledFingerprinter -- fallback when rule fails to load.
type disabledFingerprinter struct{}
// ... implement Module interface, return Matched=false from Fingerprint

Pattern: always register something (even a disabled stub) so registry lookups succeed and the scanner can skip gracefully.

4. Blank-import in main.go

Add to collector/cmd/agenthound/main.go:

_ "github.com/adithyan-ak/agenthound/modules/yourservicefp"

5. Add to the collector allowlist

Add the module package and any new dependency packages to scripts/collector-allowlist.txt. CI rejects packages that cross the collector dependency boundary without an allowlist entry.

6. Write tests

Use httptest.Server to mock the target service. Test both the matching and non-matching cases:

func TestFingerprint_Match(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{"version": "1.0.0"})
    }))
    defer srv.Close()

    fp, err := New()
    require.NoError(t, err)

    target := action.Target{Address: srv.Listener.Addr().String(), Meta: map[string]string{"scheme": "http"}}
    result, err := fp.Fingerprint(context.Background(), target)
    require.NoError(t, err)
    assert.True(t, result.Matched)
}

Optional Sidecar Interfaces

FlagsModule -- Per-Module CLI Flags

For modules that need CLI flags beyond the standard set:

import "github.com/spf13/pflag"

func (f *YourLooter) RegisterFlags(fs *pflag.FlagSet) {
    fs.Bool("include-embeddings", false,
        "Issue benchmark embedding request (consumes operator-billed compute)")
    fs.String("api-key", "",
        "Optional service API key; enables authenticated enumeration")
}

The CLI dispatcher calls module.RegisterFlagsFor(cmd, mod) which type-asserts for FlagsModule. Flag values are available at dispatch time via LootOptions.Extras or PoisonPayload.Extras.

StatefulModule -- Receipt Persistence

For destructive modules (Poisoner, Implanter) that need revert capability:

type YourPoisoner struct {
    state *module.FileStatefulModule
}

func New() *YourPoisoner {
    return &YourPoisoner{
        state: module.NewFileStatefulModule("yourservice.poison"),
    }
}

func (p *YourPoisoner) StateDir() string { return p.state.StateDir() }
func (p *YourPoisoner) WriteReceipt(engagementID string, r action.Receipt) (string, error) {
    return p.state.WriteReceipt(engagementID, r)
}
func (p *YourPoisoner) ReadReceipts(engagementID string) ([]action.Receipt, error) {
    return p.state.ReadReceipts(engagementID)
}

Receipts are stored at ~/.agenthound/state/<module-id>/<engagement-id>.json with mode 0o600. A committing module persists the receipt BEFORE it issues the mutating write -- and the receipt is written exactly once, with no post-mutation re-write -- so a crash after the mutation still leaves a revert path. Dry-run receipts (which mutate nothing) are persisted by the CLI after the module returns.

Registry Lookup

Modules are resolved by the CLI and scanner via:

module.Get("ollama.fingerprint")                    // by ID
module.ListByAction(action.Fingerprint)             // all fingerprinters
module.GetByTarget("ollama", action.Fingerprint)    // by (target, action) pair

Checklist

  • [ ] Implements one action interface from sdk/action/
  • [ ] Implements sdk/module.Module
  • [ ] Has register.go with init() calling module.Register()
  • [ ] Blank-imported in collector/cmd/agenthound/main.go
  • [ ] Module package and any new dependencies added to scripts/collector-allowlist.txt
  • [ ] Tests cover match, no-match, and error cases
  • [ ] make build-collector && make deps-check passes
  • [ ] IsDestructive() returns true for Poisoner/Implanter modules
  • [ ] Fingerprint rule YAML added to sdk/rules/builtin/fingerprints/ (for fingerprinters)