block / block/buzz

Feature request: a workflow action that runs an allowlisted local command

Open
#4,884 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## Summary

`TriggerDef::Schedule` gives workflows a real cron/interval scheduler, but `ActionDef`
(`crates/buzz-workflow/src/schema.rs:92`) can only *communicate*:

```
SendMessage · SendDm · SetChannelTopic · AddReaction · CallWebhook · RequestApproval · Delay
```

There is no way for a scheduled workflow to cause work to happen on a machine. In practice
that means the scheduler can't be used for the most common reason people want a scheduler —
running a job — without building a separate HTTP surface to receive the trigger.

I'd like an action along the lines of `RunCommand`: a workflow step that invokes a
**pre-registered, allowlisted** command on a host, rather than an arbitrary shell string.

## Why `CallWebhook` doesn't cover it

It's the documented workaround, and it works, but the cost is disproportionate for a local job:

1. **`check_ssrf` rejects private and reserved addresses**
(`crates/buzz-workflow/src/executor.rs:745`, `:766`). That's correct and I'm not asking
for it to be relaxed — but it means a workflow *cannot* reach a service on the same host
or LAN as the relay. The only path is out through the public internet and back.
2. So triggering a local job requires **exposing a new authenticated HTTPS endpoint** to the
internet. For a nightly cache refresh on a box that already sits next to the relay, that
is a real security surface added purely to receive a timer tick.
3. Every user who wants a scheduled job writes the same receiver: auth check, ACK, detach,
report. That's a missing primitive, expressed as boilerplate.

## Proposed shape

Deliberately **not** arbitrary shell — the value is in a narrow interface:

```yaml
trigger:
schedule:
cron: "30 10 * * *"
actions:
- run_command:
command: decksmith-sync # id of a registered command, NOT a shell string
args: ["--store", "all", "--full"]
timeout: 20m
capture_output: true
- send_message:
text: "Inventory sync: {{ steps.0.status }} — {{ steps.0.stdout_tail }}"
```

Sketch of the properties that seem to matter:

- **Registration is out-of-band and host-side** — an operator declares
`decksmith-sync → /usr/local/bin/decksmith-sync` in host config. The workflow can only
name a registered id; it can never introduce a new executable.
- **Args allowlisted or schema-constrained per command**, so a workflow can't smuggle
behavior through argv.
- **Runs under the existing systemd sandbox**, same posture as agent units
(`NoNewPrivileges`, `ProtectSystem=strict`, dedicated service user, declared
`ReadWritePaths`). The unit stays the authority boundary.
- **Long jobs shouldn't block the executor** — either async with completion posted as a
follow-up event, or a documented timeout ceiling.
- **Output redaction** — and per the lesson in this codebase's own broker work, error text
must not echo caller-supplied values, or redaction becomes a confirmation oracle.
- Presumably gated by channel role, and worth considering whether it should be
owner-only by default given it is the most powerful action in the set.

## Use case that motivated this

A nightly inventory sync (~11 min) on the same host as the relay. Everything else about it
is a natural fit for Buzz: the schedule, reporting results into a channel, alerting on
failure, and an agent in that channel answering questions about the data afterward.

The only missing piece is "start the job", and closing that one gap currently costs a
public HTTPS endpoint. With `RunCommand` the entire job is one workflow definition and no
new attack surface.

Other shapes this would cover: backups, mirror/reconciler ticks, health probes that need
a local binary, cache warms, report generation.

## Alternatives considered

| Alternative | Why it falls short |
|---|---|
| `CallWebhook` → local receiver | Works today; costs a public endpoint + per-user boilerplate (above) |
| Schedule → `SendMessage` → agent runs it via MCP shell | Puts an LLM in the path of a deterministic job; long tool calls are fragile |
| Keep cron/systemd, use Buzz only for reporting | What most people will do — but then the scheduler in Buzz goes unused for its main purpose |

Happy to help shape or implement this if the direction is welcome.

---

*Verified against a local checkout at `44599e7`. Line references are from that tree.*

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.