Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions tools/loop-action/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,57 @@ Instead of writing complex bash scripts to enforce circuit breakers, run readine
| `command` | **Yes** | | The actual terminal command to invoke your agent (e.g., `claude`, `grok`, `aider`). |
| `level` | No | `L1` | The autonomy level for budget constraint verification (`L1`, `L2`, `L3`). |
| `sandbox` | No | `false` | If `'true'`, routes the execution through `loop-sandbox` to provide ephemeral worktree isolation, capturing changes securely without destroying the main working tree. |
| `sandbox-shell` | No | `false` | If `'true'`, routes the execution inside a shell environment (`--shell`). |
| `sandbox-shell` | No | `false` | Compatibility option for `loop-sandbox`: if `'true'`, passes `--shell` so `loop-sandbox` itself invokes the command through its own shell. `command` is always executed as a Bash script regardless of this setting (see below) — `sandbox-shell` does not gate shell syntax and can be left at its default in normal use. |

## What it does under the hood

When this action runs, it sequentially executes:
1. **`loop-audit`**: Checks the repository against the Loop Readiness rubric (ensuring `STATE.md`, `gate.yaml`, and constraints are in place).
2. **`loop-context`**: Acts as a circuit breaker. It halts the workflow immediately if the loop is stuck in a failure cycle or has exhausted its daily token budget.
3. **Execution (`loop-sandbox`)**: If `sandbox: 'true'`, it dynamically isolates the agent inside an ephemeral git worktree, tracks all modifications into a patch file, and cleans up. Otherwise, it executes the command directly in the main tree.
3. **Write command script**: `command` is passed through the step's `env:` map (not spliced into the `run:` script text) and written verbatim to a temp file under `$RUNNER_TEMP`. This keeps multi-line commands, quotes, and shell metacharacters intact as a single script instead of being re-parsed by the surrounding workflow YAML.
4. **Execution (`loop-sandbox`)**: If `sandbox: 'true'`, that script is invoked as `bash -eo pipefail <script>` inside an ephemeral git worktree via `loop-sandbox`, which tracks all modifications into a patch file and cleans up. Otherwise, it executes `bash -eo pipefail <script>` directly in the main tree.

## Safe usage of `command`

`command` is always executed as a Bash script via the temporary script file
written in step 3 above — this is true whether `sandbox` is `'true'` or
`'false'`, and regardless of `sandbox-shell`. The script runs with `bash -eo
pipefail`, so:
- any command that exits non-zero stops the script immediately (`set -e`)
- a failing command anywhere in a pipeline fails the whole pipeline
(`pipefail`), not just its last stage

`sandbox-shell` only controls whether `loop-sandbox` additionally routes the
`bash <script>` invocation through its own internal shell (`--shell`) before
running it — it is a `loop-sandbox` compatibility knob, not something you
need to set to get shell syntax (pipes, `&&`, multi-line scripts, etc.) in
`command`. Those already work through the temporary script regardless of
`sandbox-shell`.

Treat `command` the same as any other `run:` step in a workflow:

```yaml
- uses: cobusgreyling/loop-engineering/tools/loop-action@main
with:
pattern: 'ci-sweeper'
level: 'L2'
sandbox: 'true'
command: |
npx grok-cli run --skill .grok/skills/ci-sweeper/SKILL.md
echo "done"
```

Multi-line `command: |` blocks are supported and run as a single atomic
script — every line executes inside the chosen path (sandbox or direct), not
just the first.

> **Warning — never embed untrusted input directly in `command`.**
> Do not interpolate values from issue titles/bodies, PR titles/bodies, commit
> messages, branch names, or any other content an external, untrusted actor
> can influence (e.g. `command: 'echo ${{ github.event.issue.title }}'`).
> `command` runs as an arbitrary shell script with the permissions of the
> job, so untrusted text placed in it is a shell/command injection
> vulnerability regardless of how the action passes the string internally.
> If you need data from the triggering event, pass it through an
> intermediate step that validates/escapes it (or write it to a file and
> have your agent read the file) instead of splicing it into `command`.
23 changes: 19 additions & 4 deletions tools/loop-action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,32 @@ runs:
--budget-from-pattern ${{ inputs.pattern }} --budget-level ${{ inputs.level }}
shell: bash

- name: Write Agent Command Script
id: write_command
env:
LOOP_COMMAND: ${{ inputs.command }}
run: |
script_path="$(mktemp "${RUNNER_TEMP:-/tmp}/loop-action-command.XXXXXX")"
printf '%s\n' "$LOOP_COMMAND" > "$script_path"
echo "script-path=$script_path" >> "$GITHUB_OUTPUT"
shell: bash

- name: Run Agent (Sandbox)
if: ${{ inputs.sandbox == 'true' }}
env:
LOOP_SCRIPT_PATH: ${{ steps.write_command.outputs.script-path }}
LOOP_SANDBOX_SHELL: ${{ inputs.sandbox-shell }}
run: |
if [ "${{ inputs.sandbox-shell }}" == "true" ]; then
npx --yes @cobusgreyling/loop-sandbox run --shell -- ${{ inputs.command }}
if [ "$LOOP_SANDBOX_SHELL" == "true" ]; then
npx --yes @cobusgreyling/loop-sandbox run --shell -- bash -eo pipefail "$LOOP_SCRIPT_PATH"
else
npx --yes @cobusgreyling/loop-sandbox run -- ${{ inputs.command }}
npx --yes @cobusgreyling/loop-sandbox run -- bash -eo pipefail "$LOOP_SCRIPT_PATH"
fi
shell: bash

- name: Run Agent (Direct)
if: ${{ inputs.sandbox != 'true' }}
run: ${{ inputs.command }}
env:
LOOP_SCRIPT_PATH: ${{ steps.write_command.outputs.script-path }}
run: bash -eo pipefail "$LOOP_SCRIPT_PATH"
shell: bash