Commands
Storm Pulse agents execute commands from a strict whitelist. Every command runs via subprocess.run(shell=False) with absolute binary paths. The dashboard sends the command name and, for commands that declare parameters, runtime values that are regex-validated (or byte-capped) before substitution. Config-level placeholders ({project_dir}, {compose_file}, {env_file}) come only from local config and can never be set from the wire.
Built-in commands
Two commands ship with every agent:
| Name | Group | Description | Timeout | Confirmation |
|---|---|---|---|---|
git_pull |
deploy | Pull latest changes from remote | 60s | No |
docker_logs |
diagnostics | Show recent service logs | 30s | No |
The built-in docker_logs command has two overridable parameters:
| Parameter | Default | Pattern | Description |
|---|---|---|---|
docker_service_name |
(from config) | [a-zA-Z0-9_-]+ |
Docker Compose service name |
tail_lines |
100 |
[0-9]{1,5} |
Number of log lines to show |
When docker_service_name has no runtime override, the value from project.docker_service_name in the config is used automatically.
Deploy-specific commands (docker build/down/up, django migrate, etc.) are project-specific. Define them in your config — see the example config for ready-to-paste TOML.
Template placeholders
Commands use placeholders that resolve from your [project] config:
| Placeholder | Source |
|---|---|
{project_dir} |
project.project_dir |
{compose_file} |
project.compose_file |
{docker_service_name} |
project.docker_service_name |
{env_file} |
project.env_file (optional) |
When env_file is not set in the TOML, the --env-file flag and its value are automatically stripped from the resolved command. You don't need two config variants.
Disabling commands
To remove a built-in command from the registry, add it to disabled_commands in the [agent] section:
[agent]
id = "stormdevelopments.ca"
pulse_token = "..."
disabled_commands = ["docker_logs"]
The command is removed at startup. It doesn't exist in the registry, so:
- HMAC-signed requests for it return "Unknown command"
- The dashboard sees it missing from the agent's
commandslist in theregistermessage and can hide the button - A deploy sequence will fail upfront validation if it includes a disabled command
To re-enable, remove the name from the list (or remove disabled_commands entirely).
Custom commands
Add new commands in the [commands.<name>] section of your TOML config. Each command is a whitelisted shell-free subprocess call.
Strongly discouraged: do not template into a shell. Every command runs
shell=False, so a parameter is always passed as one literal argument and is never parsed by a shell. The one way to lose that protection is to make the binary itself a shell interpreter,["/bin/bash", "-c", "{script}"],["/bin/sh", "-c", ...],["/usr/bin/python", "-c", ...], and template a parameter into it. Do that and you have handed arbitrary execution to anyone who can drive the dashboard, which is exactly the attack the whitelist exists to stop. You can write such a command. You should not. It defeats the entire point of the security-first whitelist model. If you genuinely need to run operator-authored shell, that is what the seal-gatedrun_verify_block/run_apply_blockbuilt-ins are for: they require an HMAC signature and disappear when the agent is sealed, so the dangerous shape lives behind a deliberate gate instead of in your everyday command list. For ordinary commands, name one specific non-shell binary and keep each parameter's pattern tight ([a-z0-9-]+, not.*) so a value cannot smuggle in an extra flag.
Required fields
| Field | Type | Description |
|---|---|---|
group |
string | Category for the dashboard UI (e.g. "maintenance", "deploy"). Must not be empty. |
command |
array of strings | The executable and arguments. First element must be an absolute path. |
timeout |
integer | Maximum execution time in seconds. Must be positive. |
Optional fields
| Field | Type | Default | Description |
|---|---|---|---|
requires_confirmation |
boolean | false |
If true, the dashboard should show a confirmation dialog before sending. |
description |
string | "" |
Human-readable description shown in the dashboard. |
sensitive_output |
boolean | false |
If true, stdout/stderr are never logged by the agent — even at DEBUG level. Use for commands that return secrets. |
long_runningis not a config field. A config command is always a subprocess. Long-running (job) commands need a handler, and a handler can only be contributed by an integration (see Architecture). Settinglong_running = truein a[commands.*]table is refused at config load, so the dashboard never sees a command that claims progress it cannot deliver. If you need a job, write an integration.
Example: restart Caddy
[commands.restart_caddy]
group = "maintenance"
command = ["/usr/bin/systemctl", "restart", "caddy.service"]
timeout = 30
requires_confirmation = true
description = "Restart Caddy reverse proxy"
This adds restart_caddy to the registry alongside the built-ins. The dashboard will see it in the register message and can render a button for it.
Example: override a built-in
Custom commands override built-ins on name collision. If your git binary is in a different location:
[commands.git_pull]
group = "deploy"
command = ["/usr/local/bin/git", "-C", "{project_dir}", "pull"]
timeout = 120
This replaces the built-in git_pull with your version. Placeholders like {project_dir} work in custom commands too.
Using placeholders in custom commands
Custom commands can use the same placeholders as built-ins:
[commands.collectstatic]
group = "deploy"
command = [
"/usr/bin/docker", "compose",
"--env-file", "{env_file}",
"-f", "{compose_file}",
"exec", "{docker_service_name}",
"python", "manage.py", "collectstatic", "--noinput"
]
timeout = 60
description = "Collect Django static files"
Validation rules
command[0]must be an absolute path (starts with/). Relative paths are rejected at config load time.- All elements in
commandmust be strings. timeoutmust be a positive integer.groupmust be a non-empty string.requires_confirmationmust be a boolean if present.descriptionmust be a string if present.long_running = trueis rejected at load: config commands are subprocess-only (jobs come from integrations).
Parameters
Commands can declare overridable parameters using [commands.<name>.params.<param>] sub-tables. This lets the dashboard send runtime overrides (e.g. {"service": "celery"}) instead of requiring a separate command per variation.
Declaring parameters
Each parameter is a sub-table under params with these fields:
| Field | Type | Required | Description |
|---|---|---|---|
placeholder |
string | Yes | The placeholder name. Must match the sub-table key. |
default |
string | No | Static default value. Omit to let the config provide the fallback. |
pattern |
string | Conditional | Regex pattern for validation (matched with re.fullmatch). At least one of pattern or max_bytes must be set. |
max_bytes |
integer | Conditional | Maximum UTF-8 byte size of the param value. Use for opaque multi-line content (Caddyfile fragments, JSON blobs) where regex can't sanely cover the syntax. At least one of pattern or max_bytes must be set. |
description |
string | No | Human-readable description. |
Why "conditional": unvalidated params are a footgun. The agent rejects any ParamDef that has neither pattern nor max_bytes set at config load time. Use pattern for structured values (names, IDs, enums). Use max_bytes when the value is opaque content the agent shouldn't try to parse. Setting both is allowed (regex matches and byte cap enforced).
Example
[commands.service_logs]
group = "diagnostics"
command = ["/usr/bin/docker", "compose", "-f", "{compose_file}", "logs", "--tail", "{lines}", "{service}"]
timeout = 30
description = "Show recent logs for a specific service"
[commands.service_logs.params.service]
placeholder = "service"
default = "web"
pattern = "[a-zA-Z0-9_-]+"
description = "Docker Compose service name"
[commands.service_logs.params.lines]
placeholder = "lines"
default = "100"
pattern = "[0-9]{1,5}"
description = "Number of log lines to show"
The dashboard sends {"service": "celery", "lines": "50"} in the command.request params to override the defaults. If a param is not sent, the default is used.
Resolution order
For each declared parameter:
- If the dashboard sends a runtime override, use it (after regex validation).
- If no override and the param has a
default, use the default. - If no override and no default (
defaultkey omitted in TOML), skip -- the config-level placeholder provides the value.
Config-level placeholders ({project_dir}, {compose_file}, {env_file}) are resolved separately and cannot be overridden from the wire.
Protected placeholders
These placeholders are reserved for config-level resolution and cannot be declared as parameters in custom commands:
project_dircompose_fileenv_file
Attempting to declare a parameter with a protected placeholder name will fail at config load time.
Note: docker_service_name is not protected. Built-in commands declare it as a parameter with default=null, which lets the config provide the default while still allowing runtime overrides from the dashboard (e.g. {"docker_service_name": "celery"}).
HMAC signing
Runtime params are included in the HMAC canonical string. The canonical format for command.request is:
v1\n{command}\n{params_canonical}\n{nonce}\n{timestamp}
Where params_canonical is the sorted key=value pairs joined by & (e.g. lines=50&service=celery). Both the dashboard and agent must produce the same canonical string for the signature to verify.
Validation
The agent validates all runtime params before execution:
- Unknown params (not declared in
ParamDef) are rejected. - Values must match the declared
patternregex (fullmatch), whenpatternis set. - Values must not exceed
max_bytesUTF-8 bytes, whenmax_bytesis set. - All param values must be strings.
How the dashboard knows what's available
On every WebSocket connection, the agent sends a register message that includes a commands field -- a dict of all available commands with full metadata from the final registry (built-ins + custom - disabled):
{
"type": "register",
"payload": {
"version": "0.1.0",
"pulse_token": "...",
"commands": {
"git_pull": {
"group": "deploy",
"description": "Pull latest changes from remote",
"template": ["git", "-C", "{project_dir}", "pull"],
"timeout": 60,
"requires_confirmation": false,
"long_running": false,
"params": {}
},
"docker_logs": {
"group": "diagnostics",
"description": "Show recent service logs",
"template": ["docker", "compose", "--env-file", "{env_file}", "-f", "{compose_file}", "logs", "--tail", "{tail_lines}", "{docker_service_name}"],
"timeout": 30,
"requires_confirmation": false,
"long_running": false,
"params": {
"docker_service_name": {
"default": null,
"pattern": "[a-zA-Z0-9_-]+",
"description": "Docker Compose service name"
},
"tail_lines": {
"default": "100",
"pattern": "[0-9]{1,5}",
"description": "Number of log lines to show"
}
}
}
}
}
}
The dashboard stores this metadata on the server record and uses it to:
- Render command buttons grouped by
group - Show
descriptionin tooltips - Display the
templateso operators can see what runs - Show a confirmation dialog when
requires_confirmationis true - Render input fields for
paramswith defaults and validation patterns - Disable the Deploy button if any sequence step is missing from the command keys
- Return an error if someone tries to send a command the agent doesn't have
If the agent reconnects with a different set of commands (e.g. after a config change and restart), the dashboard updates automatically.
Older agents that don't send commands will have null -- the dashboard should treat this as "all built-in commands available" for backward compatibility.
Execution model
All commands run with:
subprocess.run(shell=False)-- no shell interpretation, no injection possiblecapture_output=True-- stdout and stderr are captured and sent back to the dashboard- Per-command timeout -- the process is killed if it exceeds the configured timeout
- Config-level placeholders from local config only -- the dashboard sends a command name and optional runtime params. Params are regex-validated against
ParamDefdeclarations before substitution. Protected placeholders (project_dir,compose_file,env_file) cannot be overridden from the wire
Result reporting
Every command execution produces a command.result message with:
| Field | Description |
|---|---|
success |
true if exit code is 0 |
exit_code |
The process exit code, or -1 for timeout/not_found/os_error |
stdout |
Captured standard output |
stderr |
Captured standard error |
duration_ms |
Execution time in milliseconds |
failure_reason |
null on success, or one of: exit_code, timeout, not_found, os_error |
Failure modes
failure_reason |
What happened |
|---|---|
exit_code |
Command ran but exited non-zero. Check exit_code and stderr. |
timeout |
Command exceeded its timeout. Partial output may be available. |
not_found |
Binary doesn't exist (e.g. Docker not installed). |
os_error |
OS-level failure (permissions, resource limits). |