Skip to content

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

Description

@jdrolls

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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions