Skip to content

feat(recipe): kind: host steps, behind two refusals - #214

Merged
kuny0707 merged 1 commit into
tronprotocol:developfrom
barbatos2011:feat/recipe-host-steps-v2
Aug 6, 2026
Merged

kuny0707 merged 1 commit into
tronprotocol:developfrom
barbatos2011:feat/recipe-host-steps-v2

Conversation

@barbatos2011

Copy link
Copy Markdown

Recipes could only call trond subcommands, so a workflow needing Toolkit.jar, a funding script, or anything else outside trond could not be expressed. This is the third and last piece of the recipe work, after #212 (steps inherit the parent's state and gate) and #213 (--file).

steps:
  - id: stage
    kind: host
    run: ["java", "-jar", "Toolkit.jar", "db", "fork"]

  - id: fund
    kind: host
    script: |
      for a in $(cat addrs.txt); do ./fund.sh "$a"; done

run: is argv and never sees a shell; script: is the only way to get one. That separation is the point — shell metacharacters in a run: item are inert, so grep -l 'script:' is a complete census of shell usage across a recipe tree. run[0] is never templated either: a substituted program name makes "what does this recipe execute" unanswerable by reading it.

Two refusals

This is the only step kind that escapes trond's own surface. A command step is bounded by what trond will do, and after #211 every node-touching trond verb is individually gated and audited. A host step is bounded by nothing.

Without --allow-host-exec, it does not run. Choosing a recipe file and opting into arbitrary execution are separate decisions, and --file means the file can come from anywhere. The flag makes "this run may execute arbitrary programs" an explicit, greppable decision rather than a property of whichever file was passed.

Under --require-private it does not run either — including with --allow-host-exec. The gate promises an agent is "mechanically incapable of mutating a mainnet/nile rig". A host step can delete a mainnet node's data directory without ever naming the node, so honouring that promise means refusing outright rather than inspecting the command and guessing what it does. The gate has no node to check here, which is precisely why it must refuse rather than allow. --allow-host-exec cannot lift the floor, and a test pins that.

--dry-run previews host steps under either refusal — printing what would run changes nothing, and the same reasoning already lets auto-heal --dry-run through the gate.

Verified end to end:

$ trond recipe run --file host.yaml                            # no opt-in
Error [RECIPE_FAILED]: step stage failed: host step refused: pass --allow-host-exec ...

$ trond --require-private recipe run --file host.yaml --allow-host-exec
Error [RECIPE_FAILED]: step stage failed: host step refused: --require-private is set,
and a host step runs outside any node's network so the gate cannot vouch for it

$ trond recipe run --file host.yaml --allow-host-exec           # permitted
  [stage] host: touch /tmp/…/RAN
recipe host-demo: status=success, 1 steps, 4ms

$ trond --require-private recipe run --file host.yaml --dry-run # preview survives
  [stage] would run on host: touch /tmp/…/RAN

In every refused case the target file was confirmed absent afterwards, not just the exit code checked.

Compatibility

The zero value of Kind is command, so every recipe written before this field existed parses and runs byte-identically — a test pins that too. Validation rejects the shapes that would otherwise be ambiguous: both run and script, neither, command on a host step, host fields on a command step, an unknown kind.

recipe-show.schema.json does not constrain step properties, so no schema change and no version bump.

Testing

  • make test -race — 25 packages, 0 failures
  • make lint — no findings
  • 14 new tests: the three-way permit/refuse matrix, dry-run preview under both refusals, argv-is-not-a-shell, dir:/env: handling with substitution, and five validation shapes

Not here

  • No per-step timeout. The runner has none for command steps either, and adding one for a single kind would be asymmetric — it wants to arrive for both at once.
  • No per-host-step audit entry. Recipe runs are not audited at all today; that is worth fixing as its own change rather than bolting onto one step kind.

Recipes could only call trond subcommands, so a workflow needing
Toolkit.jar, a funding script or anything else could not be expressed.
`kind: host` runs a program on the machine running trond:

    steps:
      - id: stage
        kind: host
        run: ["java", "-jar", "Toolkit.jar", "db", "fork"]
      - id: fund
        kind: host
        script: |
          for a in $(cat addrs.txt); do ./fund.sh "$a"; done

`run:` is argv and never sees a shell; `script:` is the only way to get
one. That separation is the point: shell metacharacters in a `run:` item
are inert, so `grep -l 'script:'` is a complete census of shell usage
across a recipe tree. run[0] is never templated either — a substituted
program name makes "what does this recipe execute" unanswerable by
reading it.

This is the only step kind that escapes trond's own surface. A command
step is bounded by what trond will do, and every node-touching trond verb
is now individually gated and audited (tronprotocol#211); a host step is bounded by
nothing. So it is refused twice:

- **Without `--allow-host-exec`.** Choosing a recipe file and opting into
  arbitrary execution are separate decisions, and `--file` means the file
  can come from anywhere. The flag makes "this run may execute arbitrary
  programs" explicit and greppable rather than a property of whichever
  file was passed.

- **Under `--require-private`, unconditionally** — including with
  `--allow-host-exec`. The gate promises an agent is "mechanically
  incapable of mutating a mainnet/nile rig". A host step can delete a
  mainnet node's data directory without ever naming the node, so
  honouring that promise means refusing outright rather than inspecting
  the command and guessing. The gate has no node to check here, which is
  exactly why it must refuse and not allow. `--allow-host-exec` cannot
  lift the floor; a test pins that.

`--dry-run` previews host steps under either refusal. Printing what would
run changes nothing, and the same reasoning already lets
`auto-heal --dry-run` through the gate.

The zero value of Kind is "command", so every recipe written before this
field existed parses and runs byte-identically. Validation rejects the
shapes that would otherwise be ambiguous: both run and script, neither,
command on a host step, host fields on a command step, an unknown kind.

Not here: no per-step timeout. The runner has none for command steps
either, and adding one for a single kind would be asymmetric — it wants
to arrive for both at once. No audit entry per host step either; recipe
runs are not audited today, and that is worth fixing as its own change
rather than for one step kind.
@kuny0707
kuny0707 merged commit c8aeb6b into tronprotocol:develop Aug 6, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants