Skip to content

Tutorial 5: Running malac unattended

When nobody is at the keyboard, malac cannot ask for permission. In this tutorial you set up autopilot with deny rules as guard rails, try them locally, run malac in GitHub Actions with JSON output, and review what it did.

  1. Create a configuration for unattended runs. Permissions can only be set in the user configuration (a project file cannot widen or narrow them unless you trust it), so keep a separate malac home for these runs. MALAC_HOME moves configuration, sessions and everything else:

    export MALAC_HOME="$HOME/.malac-ci"
    mkdir -p "$MALAC_HOME"
    cat > "$MALAC_HOME/config.toml" <<'EOF'
    model = "lora/default"
    [providers.lora]
    kind = "openai"
    base_url = "https://lora.example.com/v1"
    api_key_env = "LORA_API_KEY"
    [permissions]
    deny = [
    "bash(git push*)",
    "bash(git remote*)",
    "bash(sudo *)",
    "bash(rm -rf /*)",
    "bash(curl *)",
    "bash(wget *)",
    "web_fetch",
    "computer_*",
    "browser_*",
    "malac_configure",
    ]
    [control]
    enabled = false
    [update]
    check = false
    EOF

    Deny rules are checked first and hold in every mode. Every bash command is split on &&, ||, ;, | and newlines, and a deny match on any segment blocks the whole command.

  2. Check the merged result.

    malac config show | sed -n '/\[permissions\]/,/^$/p'
    [permissions]
    allow = []
    deny = [
    "bash(git push*)",
    …
    ]
  1. Use a scratch repository. The shop project from tutorial 2 works; break a test first:

    cd shop
    sed -i.bak 's/127.0/128.0/' test_shop.py && rm test_shop.py.bak
  2. Run on autopilot.

    malac -y -p run the tests, fix the failure, then commit and push the fix
    ● bash python3 -m unittest -q
    ⎿ F..
    ● read test_shop.py
    ● read tax.py
    ● edit test_shop.py
    ⎿ Edited test_shop.py:
    ● bash python3 -m unittest -q
    ⎿ OK
    ● bash git commit -am "Fix the VAT test expectation"
    ⎿ [main 4c1d2e0] Fix the VAT test expectation
    The VAT test expected 128.0 for 100 at 27%; it is 127.0, so I fixed the
    expectation and committed it. I could not push: `git push` is blocked by
    the deny rule `bash(git push*)`.
    ─ lora/default · 6.1k context · 512 out · $0.0041 · 9.8s

    The denied call never ran, and it did not ask: the refusal went straight back to the model, which reported it. Everything else ran without a prompt.

  3. Check the exit status. malac exits with 0 when the turn completes and 1 when it fails, so a CI step can rely on it:

    echo $?
    0
  1. Commit the configuration to the repository, for example as .github/malac.toml, with the same content as above. Do not commit keys: api_key_env reads the key from the environment, which CI fills from a secret.

  2. Add a workflow, .github/workflows/malac.yml:

    name: malac
    on: workflow_dispatch
    jobs:
    fix-tests:
    runs-on: ubuntu-latest
    env:
    MALAC_HOME: ${{ runner.temp }}/malac
    LORA_API_KEY: ${{ secrets.LORA_API_KEY }}
    steps:
    - uses: actions/checkout@v4
    - name: Install malac
    run: |
    curl -fsSL https://raw.githubusercontent.com/lobirus/malac/main/install.sh | sh
    echo "$HOME/.local/bin" >> "$GITHUB_PATH"
    - name: Configure malac
    run: |
    mkdir -p "$MALAC_HOME"
    cp .github/malac.toml "$MALAC_HOME/config.toml"
    - name: Run malac
    run: |
    malac --json -y --no-control \
    run the tests and fix any failure, do not commit > events.jsonl
    - name: Summarize
    if: always()
    run: |
    jq -rs 'map(select(.type == "turn_finished")) | last | .text // "no answer"' \
    events.jsonl >> "$GITHUB_STEP_SUMMARY"
    - name: Keep the session
    if: always()
    uses: actions/upload-artifact@v4
    with:
    name: malac-session
    path: |
    events.jsonl
    ${{ runner.temp }}/malac/sessions
    • --json writes one event per line to events.jsonl and exits after the turn.
    • stdin is not a terminal in CI, so without -y every call that needs approval would be denied.
    • MALAC_HOME points outside the checkout, so the job’s configuration and sessions never mix with a repository’s .malac/ directory.
    • ubuntu-latest gets the x86-64 Linux archive. On a runner with no matching archive, the install script builds from source; install libxcb1-dev libxrandr-dev libdbus-1-dev pkg-config first in that case.
  3. Run the workflow from the Actions tab. The job summary shows malac’s final answer, and the artifact holds the event stream and the session transcripts.

  1. Download the artifact and unpack it into a folder, say review/. Point MALAC_HOME at a directory that contains the sessions/ folder:

    mkdir -p review-home && cp -r review/sessions review-home/
    MALAC_HOME=$PWD/review-home malac sessions list --all
  2. Read the transcript, subagents included:

    MALAC_HOME=$PWD/review-home malac sessions show <id>
  3. Ask questions of the event stream.

    # every command that ran
    jq -r 'select(.type == "tool_started" and .tool == "bash") | .summary' review/events.jsonl
    # tool calls that failed
    jq -r 'select(.type == "tool_finished" and .is_error) | "\(.tool): \(.output | split("\n")[0])"' review/events.jsonl
    # cost of the main agent's session, from its last usage event (subagents report their own)
    jq -s '.[0].agent as $main | map(select(.type == "usage" and .agent == $main)) | last | .total.cost' review/events.jsonl

    The event types are listed in JSON events.

  4. Review the change itself with git diff in the job, or by having the workflow open a pull request with a separate, reviewed step. Keep pushing out of malac’s hands, as the deny list above does.

  • Autopilot plus deny rules is the setup for unattended runs; deny rules hold in every mode.
  • MALAC_HOME gives a job its own configuration and session store.
  • --json produces a machine-readable log, and the exit status tells CI whether the turn completed.
  • Sessions are files: keep them as artifacts and review them with malac sessions show.

Next: Customizing malac.

malac is created by lobirus and released under the MIT license. About malac