emisar runner

September 5, 2026 ยท View on GitHub

The runner is the local enforcement and execution layer for emisar. It loads the action packs installed on a host, dials out to the control plane, and checks every dispatched action against the local pack before it starts a process. The runner opens an outbound TLS WebSocket and exposes no inbound listener; commands return through that established connection.

Run one runner on every host that an agent should be able to inspect or change. The runner uses the permissions of its service user; emisar does not turn a permitted command into a sandbox.

Install and prove it works

The supported production target is Linux with systemd. macOS with launchd is available for development and evaluation. Install GitHub CLI with gh attestation verify --bundle to check release signatures, and allow HTTPS to tuf-repo-cdn.sigstore.dev:443 and tuf-repo.github.com:443 so it can load the public trust roots; without GitHub CLI the installer and emisar update ask at a terminal, or warn and continue unattended, on the checksum alone. Release verification does not require a GitHub login.

  1. In the emisar console, choose Connect a runner. The generated command contains the control-plane URL and a fresh, single-use enrollment key.

  2. Run that command on the host:

    curl -fsSL https://emisar.dev/install.sh \
      | sudo EMISAR_ENROLLMENT_KEY=emkey-enroll-... EMISAR_URL=https://emisar.dev bash
    

    The installer authenticates the signed release checksum, verifies the archive against it, creates a dedicated emisar user on Linux, installs the service, adds host-matched starter packs, and starts the runner.

  3. Verify the host and the control-plane connection:

    sudo emisar status
    
  4. Confirm the runner is online in the console. Dispatch linux.uptime with a reason and check that the result appears in the audit trail.

emisar status reads the running daemon's owner-only local snapshot and reports its last successful heartbeat send, advertised catalog, process uptime, and in-flight run count. The console remains authoritative for whether the control plane currently sees the runner. If status reports a problem, emisar doctor checks configuration, credentials, pack contents, required host binaries, and control-plane reachability without opening a cloud session.

The complete operator walkthrough is at emisar.dev/docs/quickstart. Container and Kubernetes installations are covered at emisar.dev/docs/containers.

What the runner enforces

For every action, the runner:

  1. Resolves the action from its own loaded pack catalog.
  2. Recomputes the pack's content hash and compares it with the hash trusted by the control plane.
  3. Re-validates every argument against the pack's typed schema and rejects unknown input.
  4. Applies the host-local action allowlist, denylist, and optional risk ceiling.
  5. Clamps timeout and output limits to the pack's declared bounds.
  6. Executes the pack-authored binary and argv with os/exec.
  7. Redacts runner output before it leaves the host. The control plane keeps the redacted output in run history and records terminal metadata in the audit.
  8. Appends the attempt to a hash-chained local JSONL journal.

Fixed shell programs may be authored inside a pack when pipes or shell features are needed, but cloud input is still limited to validated substitutions. The staging-only shell pack is the explicit arbitrary-command exception and must not be installed on production runners.

See the security model, architecture, and runner wire protocol for the full contract.

Files and configuration

The supervised installer uses these paths by default:

PathPurpose
/usr/local/bin/emisarRunner and operator CLI
/etc/emisar/config.yamlHost identity, control-plane URL, packs, admission, and execution settings
/etc/emisar/runner.envMode-0600 enrollment key and pack credentials
/etc/emisar/packs/Installed action packs
/var/lib/emisar/Per-runner token and durable execution state
/var/lib/emisar/runtime-status.jsonMode-0600 advisory daemon health snapshot for emisar status
/var/log/emisar/events.jsonlHash-chained local security journal

The portal-generated install command writes the URL and enrollment key for you. For manual provisioning, the minimum shape is:

schema_version: 1

runner:
  group: web-prod
  labels:
    region: us-east-1
    environment: prod

cloud:
  url: wss://emisar.dev
  enrollment_key_env: EMISAR_ENROLLMENT_KEY
  token_path: /var/lib/emisar/token

paths:
  data_dir: /var/lib/emisar
  packs:
    - /etc/emisar/packs

execution: {}

events:
  jsonl_path: /var/log/emisar/events.jsonl

Groups and labels organize the fleet and participate in scoping. Treat their names as durable operational identifiers. The full annotated configuration is examples/config.yaml.

Pack credentials

Pack credentials stay on the host. They are never passed as action arguments.

  1. Put each value in /etc/emisar/runner.env:

    NOMAD_ADDR=http://127.0.0.1:4646
    NOMAD_TOKEN=<acl-token>
    
  2. Allowlist the variable names in /etc/emisar/config.yaml:

    execution:
      inherit_env:
        - NOMAD_ADDR
        - NOMAD_TOKEN
    
  3. Restart the service so both files are re-read:

    sudo systemctl restart emisar
    

The runner always provides PATH, LANG, LC_ALL, and TERM; everything else is dropped unless it is allowlisted. Run emisar pack info <id> to see a pack's binaries, environment variables, privilege needs, and verification action. The installer preserves both configuration files during upgrades.

Install and manage packs

The installer adds a small host-matched starter set. Add capabilities by name from the public registry, by pinned version, from a local directory, or from an HTTPS tarball:

sudo emisar pack install redis
sudo emisar pack install redis=0.2.3 --hash sha256:...
sudo emisar pack install ./my-pack
sudo emisar pack info redis

The runner validates every pack before installing it. When a daemon is running, pack install, update, and uninstall signal it to reload and re-advertise without dropping in-flight work. Otherwise run sudo systemctl reload emisar.

Browse the catalog at emisar.dev/packs and read the pack guide before installing capabilities on production hosts.

Local admission

Admission is the host's defense-in-depth gate. It hides and refuses actions that should never be available on that runner, even if the control plane asks for one.

admission:
  allow:
    - "linux.*"
    - "postgres.uptime"
  deny:
    - "*.repair"
    - "linux.systemctl_restart"
  max_risk: medium

An action must match allow when that list is present, must not match deny, and must not exceed max_risk. Empty admission settings accept every action in the locally installed catalog.

Operations

TaskLinuxmacOS
Startsudo systemctl start emisarsudo launchctl bootstrap system /Library/LaunchDaemons/com.emisar.runner.plist
Stopsudo systemctl stop emisarsudo launchctl bootout system /Library/LaunchDaemons/com.emisar.runner.plist
Restartsudo systemctl restart emisarbootout, then bootstrap
Runner healthsudo emisar statussudo emisar status
Service statesudo systemctl status emisarsudo launchctl print system/com.emisar.runner
Follow logssudo journalctl -u emisar -ftail -f /var/log/emisar/emisar.err.log
Reload packs and signing trustsudo systemctl reload emisarsudo launchctl kill HUP system/com.emisar.runner
Tail the local journalsudo emisar events tailsudo emisar events tail
Verify the journal chainsudo emisar audit verifysudo emisar audit verify

The Linux unit uses Restart=on-failure, a five-attempt restart burst cap, and a seven-minute graceful shutdown window. The cap prevents a bad or revoked credential from causing an endless authentication loop. The shutdown window covers the longest bundled cancellation grace.

The default 30-second heartbeat pairs with the portal's stale-socket watchdog and connection lease. The portal closes a connection 90 seconds after the last heartbeat, so cloud.heartbeat_every is capped at 45 seconds and a wider value is refused at load rather than becoming a silent reconnect loop. A half-open network path can take roughly 90-120 seconds to release ownership before a replacement connection is accepted. Reducing the runner's reconnect backoff does not bypass that safety window.

Upgrade and remove

An installer-managed runner updates itself without changing its configuration, credentials, packs, or local evidence:

sudo emisar update

Pin a reviewed release when needed:

sudo emisar update --version X.Y.Z

The command works only for runners installed by install.sh. Copied binaries, containers, packages, and infrastructure-managed runners must be updated by their deployment system. The updater downloads the release, verifies the signed SHA256SUMS metadata and archive checksum, then runs the installer bundled in that release.

Fresh unattended installs require an explicit pack set. Pass reviewed pack IDs or set an empty value to add none:

curl -fsSL https://emisar.dev/install.sh | sudo EMISAR_PACKS="" bash -s -- --yes

To remove the service while retaining configuration and local evidence:

sudo bash install.sh --uninstall

The default uninstall deletes the cached runner token but keeps /etc/emisar, /var/lib/emisar, and /var/log/emisar. Add --purge to delete them too.

Signed dispatch (optional)

A runner can require every action to carry intent signed by the MCP bridge with an Ed25519 or ECDSA P-256 leaf key. The control plane can relay that action but cannot originate it, change its exact arguments, or widen its runner set.

Run emisar signing init, add the generated CA public key under signing.trusted_cas, and configure the MCP bridge with the leaf key and certificate. Setup, scope, rotation, replay protection, and refusal codes are in the signed-dispatch specification.

Hardening (optional)

On Linux, every action child always enters no_new_privs before it executes. That is part of the runner's execution boundary, independent of systemd and not an optional hardening setting. It prevents setuid/setgid helpers and file capabilities on action binaries from adding privileges anywhere in the action's process tree.

The installed systemd unit is deliberately modest because every service sandbox directive also constrains the actions it launches. For example:

DirectiveCommon consequence
ProtectSystem=strictBlocks actions that write outside declared writable paths
ProtectHome=yesBlocks reads under /home
ProtectProc=invisibleBreaks process and /proc diagnostics
PrivateDevices=yesBlocks storage and device actions
MemoryDenyWriteExecute=yesBreaks JIT runtimes

Add a systemd drop-in only after checking every installed pack against the restrictions. Drop-ins survive installer upgrades. A strong read-mostly host profile can start with:

[Service]
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/emisar /var/log/emisar
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictRealtime=yes
LockPersonality=yes
SystemCallArchitectures=native

Install it under /etc/systemd/system/emisar.service.d/harden.conf, run sudo systemctl daemon-reload, restart the service, and use emisar doctor plus representative local action runs to prove the profile fits the host.

Giving actions the OS access they need

The default Linux service user is unprivileged. Grant only the OS authority required by the packs installed on that host. An action cannot gain authority through sudo, another setuid/setgid helper, or file capabilities on the binary it executes: no_new_privs blocks all three. A sudoers rule for emisar will never elevate an action.

Prefer direct, narrow access. Add the emisar user to a group that already owns the resource, or grant an ACL on the exact socket, file, or directory the pack needs. For a privileged operation exposed by a local service, use that service's authorization boundary instead of changing the action process's identity.

For systemd actions, prefer a narrow polkit rule:

polkit.addRule(function(action, subject) {
    if (action.id == "org.freedesktop.systemd1.manage-units" &&
        subject.user == "emisar") {
        var unit = action.lookup("unit");
        if (unit == "nginx.service" || unit == "cassandra.service") {
            return polkit.Result.YES;
        }
    }
});

An action may declare execution.user to drop to a different local identity, but the runner service must already have authority to make that switch. The default unprivileged service cannot become another user. Do not run the service as root in production merely to make an action work; that turns a runner compromise into full host compromise. Give the dedicated service identity the smallest direct or mediated access the installed packs actually need.

Development

Build from the repository root:

(cd runner && go build -o ../bin/emisar .)
./bin/emisar --config ./runner/examples/config.yaml state

The module gate is:

./run gate runner

CLI commands live at the module root. Runtime packages are under internal/; the public pack manifest types are in pkg/actionspec and pkg/packspec. Read AGENTS.md before changing the execution or trust boundary.