Skip to main content
  1. Documentation/
  2. Developer Guide/

Declaring Command Schemas

Table of Contents
The Command framework lets you declare what arguments a command accepts once, as a class attribute. That declaration drives CLI tab-completion, web UI input forms, and server-side payload validation — with no per-command client code required.

Overview
#

A Command is a fluent builder that produces a structured argument schema. Declare Command instances as class attributes on any plugin class and the framework auto-discovers them at class-creation time via __init_subclass__.

Plugin typeHow schemas are declaredAuto-discovery
Agent package (built-in commands)Command class attributes on AgentPackageBase subclassYes — __init_subclass__
Agent module (Python wheel package)Command class attributes on AgentModuleBase subclassYes — __init_subclass__
Agent module (YAML manifest)options_schema block — auto-convertedN/A (manifest parsed at load)
Tool plugin (agentless module)Command class attributes on AgentlessModuleBase subclassYes — __init_subclass__

Regardless of source, all schemas end up in the same wire format served from the capabilities API endpoint.


The Command Builder
#

Both Command and its backward-compatible alias CommandSchema live in tantoc2.server.command_schema.

1
from tantoc2.server.command_schema import Command, AgentFilter, ModuleFilter

Constructor
#

1
Command(name, description="")

Fluent argument methods
#

1
2
.arg(name, *, type="str", desc="", required=_SENTINEL, default=_SENTINEL,
     choices=None, choices_from=None, positional=True)

Add a primitive argument. Smart defaults:

  • If default is provided (even None), required defaults to False.
  • If no default is given and required is not explicit, required defaults to True.
  • positional=True by default; arguments are auto-numbered starting at 0.
1
.path(name, *, desc="", required=_SENTINEL, default=_SENTINEL)

Shorthand for .arg(name, type="path", ...) — a remote filesystem path.

1
.file(name, *, desc="", required=_SENTINEL, default=_SENTINEL)

Shorthand for .arg(name, type="file", ...) — a file to upload. Defaults to required=True.

Entity methods
#

Entity arguments let clients look up live server data for completion and form rendering.

1
2
3
4
.agent(name, *, desc="", required=_SENTINEL, filter=None)
.listener(name, *, desc="", required=_SENTINEL, filter=None)
.module(name, *, desc="", required=_SENTINEL, filter=None)
.credential(name, *, desc="", required=_SENTINEL, filter=None)

Entity methods default to required=True. The filter parameter takes a list of filter enum values.

Filter enums
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from tantoc2.server.command_schema import AgentFilter, ListenerFilter, ModuleFilter, CredentialFilter

class AgentFilter(StrEnum):
    ACTIVE = "active"
    RELAY_CAPABLE = "relay"
    SESSION_MODE = "session"
    BEACON_MODE = "beacon"

class ListenerFilter(StrEnum):
    RUNNING = "running"
    STOPPED = "stopped"
    EXTERNAL = "external"
    NON_EXTERNAL = "non_external"

class ModuleFilter(StrEnum):
    COMPATIBLE = "compatible"
    LOADED = "loaded"

Argument Types
#

Primitive types
#

TypeDescription
"str"Free-form string (default)
"int"Integer — coerced from string before validation
"float"Floating-point number
"bool"Boolean — accepts "true", "1", "yes", "on" / "false", "0", "no", "off"

Path types
#

TypeDescription
"path"Filesystem path on the agent (remote)
"local_path"Filesystem path on the operator machine — CLI offers tab-completion
"file"A file to be read and uploaded — web UI renders a file picker

Entity types
#

Entity types cause clients to fetch live data for completion and validation. Server-side entity existence is checked when the task is dispatched, not during payload validation.

TypeDescription
"agent"An agent ID from the current engagement
"listener"A listener ID from the current engagement
"module"An agent module name from the registry
"credential"A credential ID from the current engagement

For Agent Package Developers
#

Class-attribute pattern (recommended)#

Declare Command instances as class attributes on your AgentPackageBase subclass. __init_subclass__ scans for them automatically — no need to override command_schemas() or built_in_commands().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from tantoc2.server.agent_package import AgentPackageBase
from tantoc2.server.command_schema import Command

class MyAgentPackage(AgentPackageBase):

    # No-argument commands
    kill    = Command("kill", "Terminate the agent process")
    whoami  = Command("whoami", "Print current user identity")
    pwd     = Command("pwd", "Print working directory")
    ps      = Command("ps", "List running processes")

    # Positional path argument, optional with default
    ls = Command("ls", "List directory contents") \
        .path("path", desc="Directory path to list", default=".")

    # Required positional path
    cat = Command("cat", "Display file contents") \
        .path("path", desc="File path to read")

    # Named (flag-style) optional arguments
    beacon_config = (
        Command("beacon_config", "Update beacon interval and jitter")
        .arg("interval", type="int", desc="Beacon interval in seconds",
             required=False, positional=False)
        .arg("jitter", type="int", desc="Beacon jitter percentage (0-100)",
             required=False, positional=False)
    )

    # String with static choices
    output_format = Command("output_format", "Set output format") \
        .arg("format", type="str", desc="Output format", default="text",
             choices=["text", "json", "csv"])

After declaring these, built_in_commands() and command_schemas() return the auto-discovered results. Do not redeclare management commands (upload, download, load, unload) — the teamserver merges them automatically.

Reference implementation: Dev agent package
#

Source: dev_agent/src/tantoc2_dev_agent/package.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class DevAgentPackage(AgentPackageBase):

    kill    = Command("kill", "Terminate the agent process")
    survey  = Command("survey", "Collect system survey information")
    pwd     = Command("pwd", "Print current working directory")
    whoami  = Command("whoami", "Print current user identity")
    ps      = Command("ps", "List running processes")
    netstat = Command("netstat", "List network connections")

    ls = Command("ls", "List directory contents").path(
        "path", desc="Directory path to list", default="."
    )
    cat = Command("cat", "Display file contents").path(
        "path", desc="File path to read"
    )
    cd = Command("cd", "Change working directory").path(
        "path", desc="Directory to change to"
    )
    env = Command("env", "List or get environment variables").arg(
        "name", type="str",
        desc="Variable name (optional, lists all if omitted)",
        default=None,
    )
    beacon_config = (
        Command("beacon_config", "Update beacon interval and jitter")
        .arg("interval", type="int", desc="Beacon interval in seconds",
             required=False, positional=False)
        .arg("jitter", type="int", desc="Beacon jitter percentage (0-100)",
             required=False, positional=False)
    )

    # command_schemas() is auto-discovered — no override needed.

Entity-type arguments
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Agent selector — only show active relay-capable agents
connect = Command("connect", "Relay through an agent") \
    .agent("relay_agent", desc="Relay agent to use",
           filter=[AgentFilter.ACTIVE, AgentFilter.RELAY_CAPABLE])

# Listener selector — only running listeners
bind = Command("bind", "Bind to a listener") \
    .listener("listener_id", desc="Listener to bind to",
               filter=[ListenerFilter.RUNNING])

# Module selector — only compatible modules
load = Command("load", "Load a module") \
    .module("module_name", desc="Module to load",
             filter=[ModuleFilter.COMPATIBLE])

# Credential selector
auth = Command("auth", "Authenticate using credential") \
    .credential("cred", desc="Credential to use")

Dynamic choices via choices_from
#

The choices_from field names a live data source resolved client-side:

1
2
3
4
5
6
Command("relay", "Use relay agent").arg(
    "relay_agent",
    type="agent",
    desc="Agent to relay through",
    choices_from="agents",
)

Positional vs flag arguments
#

positionalCLI behaviourWeb UI behaviour
True (default)Positional by auto-incremented indexOrdered field without label prefix
False--name value flagLabelled text/checkbox field

Positional arguments are ordered by position (0-based, auto-assigned).


For Agent Module Developers (Python wheel packages)
#

Class-attribute pattern
#

Use the same Command class attributes on your AgentModuleBase subclass:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from tantoc2.server.agent_module_base import AgentModuleBase
from tantoc2.server.command import Command

class SysinfoModule(AgentModuleBase):
    name = "sysinfo"
    description = "Collect detailed system information"
    format = "py"
    payload_file = "sysinfo.py"

    sysinfo = Command("sysinfo", "Collect system info") \
        .arg("verbose", type="bool", desc="Include extended details", default=False)

__init_subclass__ auto-discovers the sysinfo command. command_schema() and all_command_schemas() return the results.

YAML manifest options_schema (legacy)
#

For YAML-manifest modules, the options_schema block is automatically converted to a Command when the manifest is loaded. This path does not support positional or entity-type filters; use the Python class approach for those features.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
name: port_scan
description: TCP port scanner

options_schema:
  target:
    type: str
    description: Target IP or CIDR range
    required: true
  ports:
    type: str
    description: "Port range (e.g., 1-1024 or 80,443)"
    required: false
    default: "1-1024"
  timeout_ms:
    type: int
    description: Connection timeout in milliseconds
    required: false
    default: 500

Supported fields per option: type, description, required, default, choices, choices_from.


For Tool Plugin Developers
#

Class-attribute pattern (recommended)#

Declare Command instances as class attributes and implement handle_<operation> methods. The base class auto-discovers commands and auto-dispatches execute() to the correct handler.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from tantoc2.server.agentless_base import AgentlessModuleBase, AgentlessResult, AgentlessTarget
from tantoc2.server.command_schema import Command

class WinRMExecModule(AgentlessModuleBase):
    name = "winrm"
    description = "Execute commands on Windows hosts via WinRM"
    author = "Your Name"
    protocol = "winrm"
    mitre_attack = ["T1021.006"]

    # Command declarations — one per operation
    exec = (
        Command("exec", "Execute command via WinRM")
        .arg("command", type="str", desc="Command to execute")
        .arg("use_ssl", type="bool", desc="Use HTTPS (port 5986)", default=False,
             positional=False)
    )
    ps = (
        Command("ps", "Execute PowerShell via WinRM")
        .arg("command", type="str", desc="PowerShell command")
    )

    # Handlers — auto-dispatched by execute()
    def handle_exec(self, targets, options, *, credentials=None, proxy=None):
        ...

    def handle_ps(self, targets, options, *, credentials=None, proxy=None):
        ...

No metadata() or execute() override needed. The base class builds metadata() from class attributes and dispatches execute() to handle_<operation>.

Reference implementation: SSH tool
#

Source: tools/ssh/src/tantoc2_tool_ssh/ssh_command.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class SSHCommandModule(AgentlessModuleBase):
    name = "ssh"
    description = "Execute commands on remote hosts via SSH"
    protocol = "ssh"
    supports_shell = True

    exec = (
        Command("exec", "Execute remote command")
        .arg("command", type="str", desc="Command to execute", required=True)
    )
    upload = (
        Command("upload", "Upload file to remote host")
        .file("local_path", desc="Local file to upload")
        .path("remote_path", desc="Remote destination path", required=True)
    )
    download = (
        Command("download", "Download file from remote host")
        .path("remote_path", desc="Remote file path", required=True)
        .path("local_path", desc="Local save path", required=False, default=".")
    )

    def handle_exec(self, targets, options, *, credentials=None, proxy=None):
        ...

    def handle_upload(self, targets, options, *, credentials=None, proxy=None):
        ...

    def handle_download(self, targets, options, *, credentials=None, proxy=None):
        ...

API Reference
#

Capabilities endpoint
#

1
GET /api/v1/agents/<id>/capabilities

The command_schemas key is a flat dict mapping command name to schema dict. It merges schemas from three sources:

  1. Agent package command_schemas() (built-in commands — auto-discovered from class attributes)
  2. Management commands (upload, download, load, unload) — always included
  3. Currently loaded agent modules (their schema merged in while running)

Example response:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
{
  "module_formats": ["py"],
  "built_in_commands": ["kill", "ls", "cat", "whoami", "ps", "netstat",
                        "upload", "download", "load_module", "unload_module"],
  "supports_daemonize": true,
  "supports_relay": true,
  "supported_relay_protocols": ["http"],
  "command_schemas": {
    "kill": {
      "command_name": "kill",
      "description": "Terminate the agent process",
      "arguments": {},
      "source": "built_in"
    },
    "ls": {
      "command_name": "ls",
      "description": "List directory contents",
      "arguments": {
        "path": {
          "name": "path",
          "type": "path",
          "description": "Directory path to list",
          "required": false,
          "default": ".",
          "positional": true,
          "position": 0
        }
      },
      "source": "built_in"
    },
    "upload": {
      "command_name": "upload",
      "description": "Upload a file to the agent",
      "arguments": {
        "local_path": {
          "name": "local_path",
          "type": "local_path",
          "description": "Local file path to upload",
          "required": true,
          "positional": true,
          "position": 0
        },
        "remote_path": {
          "name": "remote_path",
          "type": "path",
          "description": "Destination path on the agent",
          "required": true,
          "positional": true,
          "position": 1
        }
      },
      "source": "management"
    },
    "port_check": {
      "command_name": "port_check",
      "description": "Check if a TCP port is open on a target host",
      "arguments": {
        "host": {
          "name": "host",
          "type": "str",
          "description": "Target host to check",
          "required": true
        },
        "port": {
          "name": "port",
          "type": "int",
          "description": "TCP port to check",
          "required": true
        },
        "timeout": {
          "name": "timeout",
          "type": "int",
          "description": "Connection timeout in seconds",
          "required": false,
          "default": 3
        }
      },
      "source": "agent_module"
    }
  }
}

Agent module detail endpoint
#

1
GET /api/v1/agent-modules/<name>

Includes a command_schema key (singular) with the module’s schema.

Agentless module list and detail endpoints
#

1
2
GET /api/v1/agentless/modules/
GET /api/v1/agentless/modules/<name>

Include a command_schemas key (plural) with one schema per operation, keyed as "{module_name}:{operation}".


Payload Validation
#

The teamserver validates task payloads against the schema before dispatching. Call validate_payload() directly in test code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from tantoc2.server.command_schema import validate_payload, Command

schema = Command("port_check") \
    .arg("host", type="str") \
    .arg("port", type="int")

errors = validate_payload(schema, {"host": "10.0.0.1", "port": "8080"})
# => []  (int is coerced from str)

errors = validate_payload(schema, {"host": "10.0.0.1"})
# => ["Missing required argument: port"]

Validation rules:

  • Required arguments missing from the payload produce an error.
  • Primitive type values (int, float, bool) are coerced from strings.
  • choices violations produce an error.
  • Entity types (agent, listener, module, credential) are not validated here — entity existence is checked when the task is dispatched.
  • Path types (path, local_path, file) are treated as strings; no filesystem check is performed server-side.

Checklist
#

When adding commands to an agent package:

  • Every command in the agent is declared as a Command class attribute
  • Required arguments are required by default (no default=) or marked required=True
  • Optional arguments have a sensible default=
  • Named (non-positional) flags use positional=False
  • Management commands (upload, download, load, unload) are not redeclared
  • Entity-type arguments use .agent(), .listener(), .module(), or .credential() with appropriate filter enums

When adding a YAML manifest options_schema:

  • Each option has type, description, and required
  • Required options have no default
  • Optional options have a sensible default
  • Type values are one of str, int, bool, float