Sealrdocs
Installation

CI and automation

Install and enrol Sealr non-interactively, wrap the steps that touch production, and fail the job on the right exit code.

CI is where an agent's work actually reaches production. The laptop has a read replica and a scratch AWS account; the pipeline has the deploy role, the migration credentials and the push token. It is also the one environment you fully control: no human decides at 2 am to run the command outside the wrapper.

That combination — real credentials, no improvisation — makes CI the first place to turn --enforce on, and usually the last place you should leave in observe mode.

Install the binary

Releases are published as archives on GitHub, with a SHA256SUMS file and a build provenance attestation. Verify both before you put the binary in a job's PATH.

VERSION=0.1.0
TARGET=x86_64-unknown-linux-gnu
ASSET="sealr-${VERSION}-${TARGET}.tar.gz"

gh release download "v${VERSION}" -R sealr-labs/sealr -p "$ASSET" -p SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS
gh attestation verify "$ASSET" -R sealr-labs/sealr
tar xzf "$ASSET"

The archive contains two binaries: sealr (the recorder) and sealr-verify (the standalone bundle verifier).

Published Linux builds target glibc (x86_64-unknown-linux-gnu). There is no musl target, so the binary will not run on an Alpine-based image — use a Debian or Ubuntu base, or build from source in the container.

Enrol without a human

sealr enroll needs an enrollment token. Tokens are single-use and expire after 24 hours: the Console burns the token atomically at enrollment, so a second job using the same secret fails with enrollment token already used. A token pasted once into a CI secret will work for exactly one run.

Three ways to live with that, in order of how much they ask of you:

No Console, no token. The job records locally and exports the evidence as a build artifact.

sealr init                    # offline mode, local trust chain
sealr run --enforce --resource db:prod-main -- psql "$DATABASE_URL" -c "$SQL"
sealr export -o "run-${GITHUB_RUN_ID}.seal"

Enforcement still works: with no policy bundle, each guard's own conservative default decides, so an unbounded DELETE is still blocked. What you give up is central policy, approvals, and a fleet view — and the bundle only exists if the job uploads it.

Labels are how per-team policy rules find this recorder. The Console stores the labels attached to the token; the --label flags set the labels used locally when guards evaluate. Pass the same list to both so the two views agree.

GitHub Actions

.github/workflows/deploy.yml
name: deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      SEALR_VERSION: 0.1.0
      # Keep the recorder home inside the workspace so every step shares it.
      SEALR_HOME: ${{ github.workspace }}/.sealr
    steps:
      - uses: actions/checkout@v4

      - name: Install sealr
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          TARGET=x86_64-unknown-linux-gnu
          ASSET="sealr-${SEALR_VERSION}-${TARGET}.tar.gz"
          gh release download "v${SEALR_VERSION}" -R sealr-labs/sealr -p "$ASSET" -p SHA256SUMS
          sha256sum --ignore-missing -c SHA256SUMS
          gh attestation verify "$ASSET" -R sealr-labs/sealr
          tar xzf "$ASSET"
          echo "${PWD}/sealr-${SEALR_VERSION}-${TARGET}" >> "$GITHUB_PATH"

      - name: Enrol the recorder
        env:
          SEALR_CONSOLE_API: ${{ secrets.SEALR_CONSOLE_API }}
          SEALR_ADMIN_KEY: ${{ secrets.SEALR_ADMIN_API_KEY }}
          SEALR_ENDPOINT: ${{ secrets.SEALR_GRPC_ENDPOINT }}
        run: |
          set -euo pipefail
          TOKEN=$(curl -fsS -X POST "$SEALR_CONSOLE_API/v1/recorders/enrollment-tokens" \
            -H "authorization: Bearer $SEALR_ADMIN_KEY" \
            -H 'content-type: application/json' \
            -d '{"labels":["env:prod","ci:github-actions"]}' | jq -r .token)
          sealr enroll --endpoint "$SEALR_ENDPOINT" --token "$TOKEN" \
            --label env:prod --label ci:github-actions
          sealr doctor

      - name: Terraform plan
        run: terraform plan -out=tfplan

      - name: Terraform apply
        run: |
          sealr run --enforce --resource cloud:aws-prod -- terraform apply -auto-approve tfplan

      - name: Database migration
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: |
          sealr run --enforce --resource db:prod-main -- \
            psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
            -c "$(cat migrations/0042_backfill_accounts.sql)"

      - name: Keep the evidence
        if: always()
        run: sealr export -o "run-${GITHUB_RUN_ID}.seal"

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: sealr-evidence
          path: run-${{ github.run_id }}.seal

Two details in that workflow decide whether the guards actually see anything:

  • The migration is passed with -c, not -f. The SQL guard reads the statement text from psql -c, --command or --command=. psql -f migration.sql is recorded as a generic exec — argv, exit code, duration, output digests — with no SQL parsing at all. A file with several statements passed via -c parses as a multi-statement operation (SQL_MULTI_STATEMENT) and takes the highest severity among them.
  • terraform apply is guarded on argv, not on the plan. The Terraform guard analyzes terraform show -json output, but the wrapper hands it the command line. State subcommands (state rm, import, taint, force-unlock, apply -replace) are recognized and require approval; an ordinary apply yields TF_HCL_UNANALYZED (WARN). Plan-level gating from a wrapped command is not implemented yet — see agent surfaces for what you can do with the plan JSON today.

GitLab CI

.gitlab-ci.yml
deploy:
  stage: deploy
  image: debian:bookworm-slim          # glibc, not Alpine
  variables:
    SEALR_VERSION: "0.1.0"
    SEALR_HOME: "$CI_PROJECT_DIR/.sealr"
  before_script:
    - apt-get update && apt-get install -y --no-install-recommends ca-certificates curl jq
    - |
      set -euo pipefail
      TARGET=x86_64-unknown-linux-gnu
      BASE="https://github.com/sealr-labs/sealr/releases/download/v${SEALR_VERSION}"
      curl -fsSLO "${BASE}/sealr-${SEALR_VERSION}-${TARGET}.tar.gz"
      curl -fsSLO "${BASE}/SHA256SUMS"
      sha256sum --ignore-missing -c SHA256SUMS
      tar xzf "sealr-${SEALR_VERSION}-${TARGET}.tar.gz"
      install "sealr-${SEALR_VERSION}-${TARGET}/sealr" /usr/local/bin/sealr
    - |
      TOKEN=$(curl -fsS -X POST "$SEALR_CONSOLE_API/v1/recorders/enrollment-tokens" \
        -H "authorization: Bearer $SEALR_ADMIN_KEY" \
        -H 'content-type: application/json' \
        -d '{"labels":["env:prod","ci:gitlab"]}' | jq -r .token)
      sealr enroll --endpoint "$SEALR_ENDPOINT" --token "$TOKEN" --label env:prod --label ci:gitlab
  script:
    - sealr run --enforce --resource repo:acme/api -- git push origin "$CI_COMMIT_REF_NAME"
  artifacts:
    when: always
    paths:
      - "run.seal"
  after_script:
    - sealr export -o run.seal

SEALR_CONSOLE_API, SEALR_ADMIN_KEY and SEALR_ENDPOINT are masked, protected CI/CD variables. gh attestation verify is not available here, so the checksum check is the integrity control; if you want provenance verification, run gh from a container that has it or verify the archive once and mirror it to your own registry.

Containers

For agents that run inside a container — a task runner, a self-hosted agent image — bake the binary in and make sealr run the entrypoint.

Dockerfile
FROM debian:bookworm-slim

ARG SEALR_VERSION=0.1.0
ARG TARGET=x86_64-unknown-linux-gnu

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl \
 && rm -rf /var/lib/apt/lists/*

RUN set -eux; \
    BASE="https://github.com/sealr-labs/sealr/releases/download/v${SEALR_VERSION}"; \
    curl -fsSLO "${BASE}/sealr-${SEALR_VERSION}-${TARGET}.tar.gz"; \
    curl -fsSLO "${BASE}/SHA256SUMS"; \
    sha256sum --ignore-missing -c SHA256SUMS; \
    tar xzf "sealr-${SEALR_VERSION}-${TARGET}.tar.gz"; \
    install "sealr-${SEALR_VERSION}-${TARGET}/sealr" /usr/local/bin/sealr; \
    install "sealr-${SEALR_VERSION}-${TARGET}/sealr-verify" /usr/local/bin/sealr-verify; \
    rm -rf "sealr-${SEALR_VERSION}-${TARGET}"*

# The recorder home must be a volume, or the spool dies with the container.
ENV SEALR_HOME=/var/lib/sealr
VOLUME ["/var/lib/sealr"]

ENTRYPOINT ["sealr", "run", "--enforce", "--resource", "db:prod-main", "--"]
docker run --rm -v sealr-home:/var/lib/sealr acme/agent-runner \
  psql "$DATABASE_URL" -c "UPDATE accounts SET tier = 'pro' WHERE id = 42"

Mount the recorder home, and initialize it once against that volume — sealr init for offline mode, or sealr enroll for connected mode. Without it the entrypoint exits 30 with recorder is not initialized. Records are written to the local spool and synced afterwards, so a container that discards /var/lib/sealr on exit discards any record that had not yet reached the Console.

Exit codes

The job must fail on a block, and it will only do that if you do not swallow the code.

CodeMeaning
0the wrapped command succeeded
20blocked — policy BLOCK, an approval denied, or an approval that could not be obtained
127the wrapped command could not be spawned
30the recorder itself failed (not initialized, unreadable key, sealr doctor unhealthy)
otherthe wrapped command's own exit code, passed straight through

sealr verify uses its own scale: 0 pass, 10 pass with findings, 11 fail. Use it in a later job to check an exported bundle.

A blocked command never runs, so the exit code is the only signal — there is no partial effect to detect. Two ways to lose it:

# Wrong: the pipeline reports tee's status, not sealr's.
sealr run --enforce -- terraform apply | tee apply.log

# Right.
set -o pipefail
sealr run --enforce -- terraform apply | tee apply.log

A command killed by a signal reports 1, because the exit status is normalized to a single byte.

Approvals in a pipeline

In enforce mode, a REQUIRE_APPROVAL verdict holds the command and asks the Console for a human decision. The wait is bounded at 120 seconds. If no one decides in that window, or the Console is unreachable, the resource binding's on_approval_unavailable decides — the default is to block, so the job exits 20.

This is short for a human in a different timezone. Treat approvals in CI as a gate for operations someone is already watching, and use BLOCK for the ones nobody should be asked about at 2 am.

On this page