Author and ship a custom Emisar pack
Execute this workflow on the customer's environment. Do not require an Emisar
source checkout, fork, build toolchain, repository instructions, or internal
contributor skill. A pack is a directory of YAML the customer owns; author it
in their repo, prove it with the installed CLIs, and certify it through the
signed-in Emisar portal.
A pack is the contract between the operator and the LLM: it declares exactly
what may run on hosts, with what arguments, at what risk. Treat every action
you author as attack surface. Author restrictively, prove each claim, and
return one evidence-backed report.
Use current public interfaces
Default to the hosted control plane at https://emisar.dev. Use a different
EMISAR_URL only when the operator identifies and trusts that deployment.
Verify commands and schemas before using them:
- Use
emisar pack --help, emisar action --help, and packctl catalog --help as the installed-version contracts.
emisar pack validate is the schema authority — it runs the exact loader
and hash path the runner enforces. When validation disagrees with any
document, including this one, the validator wins.
- Use the public guides at
https://emisar.dev/docs/publishing-packs
(authoring), https://emisar.dev/docs/action-packs (full YAML reference),
https://emisar.dev/docs/pack-registry (self-hosted registries), and
https://emisar.dev/docs/mcp-reference (MCP catalog contract).
- Use the signed-in portal's Packs page for trust decisions and each
runner's Advertised actions for what the fleet actually serves.
- Study installed packs as worked examples:
emisar pack install <name> --dest ./examples fetches a public pack you can read; pick one whose shape matches
the job (exec reads, script actions, credentialed services).
Never reconstruct a YAML field, flag, or config shape from memory when the
validator, installed help, or a public reference can confirm it.
Safety rules
- The pack is a security boundary, not a convenience. Every action you add
is something an LLM may execute on hosts. When in doubt, narrow the action,
raise its risk tier, or leave it out. Never loosen validation, lower a risk
label, or widen
execution.inherit_env to make a check pass.
- Trust authority is a human decision, never yours. Present the content
hash, the diff, and your risk assessment; on hosted Emisar an account admin
reviews the pending pack and clicks Trust in the portal. Do not press for
approval, never click Trust, and never change which catalog the portal reads
(step 6). Never call a pack trusted or certified until you have verified
that exact hash is trusted for the account.
emisar action run executes locally and bypasses cloud policy and
approvals. Use it only for risk: low read actions, only on a
development or staging runner, never on a production host. Prove mutating
actions through the cloud path, where policy and approvals apply.
- Treat pack credentials, API tokens, and signing material as secrets. Secrets
ride
execution.env from the runner's protected environment file — never
argv, never YAML literals, never stdout, never shell history. Mark secret
args sensitive: true.
- Pack bytes are public to the fleet: hashed, advertised, and shown in the
console. Nothing secret goes in a pack directory.
- If an action needs protected local files, sockets, groups, service control,
or root, map it in
setup.host_access. Name the exact actions, provide a
persistent operator-run grant plus a verification command, and state the
authority granted to the runner identity. Emisar displays these commands;
it never runs them. Do not suggest action-time sudo: action children run
with no_new_privs and cannot elevate that way.
- Keep publisher credentials off fleet hosts.
packctl runs on a workstation
or CI job; runners only ever fetch and verify.
- Pin installs with
--hash everywhere past the first authoring host. Never
install a pack whose content you have not validated or reviewed.
1. Scope the pack
Settle what the pack is for before writing YAML:
- The job: which service, CLI, or procedure the operator wants the LLM to
handle, and the smallest action set that covers it. Prefer a few precise
actions over a wrapper for every subcommand.
- The fleet: which runners get it, and how many hosts. A handful of hosts
installs directly from a directory; a large fleet or CI-driven rollout
wants a private registry (step 5).
- The authoring host: a development or staging runner where local proof is
safe. Record
emisar --version and emisar pack list there.
- The pack id: pick one that is not taken by the public catalog
(
curl -fsS https://emisar.dev/packs.json lists public ids) so registry
installs never resolve ambiguously. Keep the directory in the customer's
own git repo.
Collect required credentials for the wrapped service the same way the runner's
other packs do — named variables in the runner's protected environment file —
without echoing values.
2. Design each action before YAML
Decide these per action, and write them down — they become the YAML:
- One action, one job, a searchable description. The MCP catalog is what
an LLM keyword-matches; open read descriptions with the verb of the job
(List, Show, Get, Tail, Check), and make
description a real doc string.
List every file, network, and process side effect under side_effects.
- Risk is honest.
low is reserved for pure reads and cheap bounded
probes — it runs without approval. Anything that mutates state, opens a
listener, binds a port, or can saturate a link is at least medium;
destructive operations are high; unrestricted escapes are critical.
Mislabeling a mutating action low bypasses the operator's approval gate.
- The LLM never controls the command.
execution.command.binary plus an
argv list is the shape; {{ args.x }} substitutes into fixed slots. The
binary is a bare PATH-resolved name (systemctl, psql). When an action
genuinely needs a pipeline, /bin/sh with a fixed -c '<script>' you
author is acceptable. Pass open-ended strings, paths, and arrays through
execution.env or as whole positional elements after the program
(["-c", "grep -- \"$1\" file", "emisar", "{{ args.pattern }}"]).
Only finite enum/allowed choices and numbers with both min and max
may render into program text. An anchored regex constrains value shape; it
is not a shell-isolation boundary.
- Bound every argument. Strings get
max_length and an anchored
pattern or an enum; numbers get min/max. An unbounded string is a
DoS hole. An anchored pattern is not path containment: . and / are
ordinary characters, so ^/var/log/myapp/.* still matches
/var/log/myapp/../../etc/shadow. A path the command reads or writes must
declare a nonempty allowed_prefixes or allowed_paths — those are the
only fields that confine it to a location. denied_prefixes/denied_paths
are optional extra exclusions, not containment: any of the four turns on the
runner's symlink-resolving canonical pass, but a deny-only rule refuses the
paths it names and accepts every other absolute path.
- A private pack may hardcode the fleet. Unlike generic public packs,
yours can enum the exact unit names, databases, and hosts it operates —
tighter than any pattern. Use that advantage.
- Design actions not to emit secrets. No environment dumps, no
credential-bearing connection strings, no unfiltered config reads.
output.redact rules scrub known shapes as a fail-closed last line, but a
secret no rule matches leaks — so don't print them in the first place.
3. Author the pack
The layout, from the authoring guide:
my-pack/
pack.yaml # manifest: metadata + which action files to load
actions/*.yaml # one action per file
scripts/*.sh # only for kind: script actions
pack.yaml declares the pack; new packs start at version: 0.1.0:
schema_version: 1
id: my-pack
name: My ops pack
version: 0.1.0
description: Short one-line summary shown on the runner and console.
vendor: acme
requires:
os: [linux]
binaries: [journalctl]
setup:
host_access:
- actions: [my.journal_tail]
requirement: Read the system journal.
recipes:
- name: systemd Linux — default emisar service user
commands:
- sudo usermod -aG systemd-journal emisar
- sudo systemctl restart emisar
verify:
- sudo -u emisar journalctl -n 1 --no-pager
impact: The emisar service identity can read the complete system journal.
actions:
- actions/journal_tail.yaml
Each action file is the full contract — this example shows the load-bearing
fields; the complete schema is in the YAML reference and the validator:
schema_version: 1
id: my.journal_tail
title: Read recent system journal entries
kind: exec
risk: low
description: >
Show the most recent system journal entries.
side_effects:
- Reads the system journal.
- Touches nothing.
args:
- name: lines
type: integer
default: 100
validation: { min: 1, max: 1000 }
execution:
command:
binary: journalctl
argv: ["--no-pager", "-n", "{{ args.lines }}"]
timeout: 10s
output:
parser: text
max_stdout_bytes: 65536
max_stderr_bytes: 8192
examples:
- title: Read the last 100 entries
args: { lines: 100 }
Write every action this deliberately: designed bounds from step 2, honest
risk, honest side effects, an example an operator would recognize.
4. Validate, then prove it locally
On the authoring runner:
emisar pack validate ./my-pack — the same checks the runner runs at
load. Fix until it reports pack <id> OK and record the printed sha256:
content hash; that exact tuple is what the portal catalog matches or an
account admin reviews.
sudo emisar pack install ./my-pack — copies it into the runner's packs
dir and reloads the running daemon itself (no restart, no dropped runs).
emisar action list and emisar action describe <id> — confirm every
action loaded with the intended risk, args, and bounds.
- Prove one
risk: low read locally: emisar action run <id> --arg k=v --reason "pack authoring check". Local runs bypass the cloud, so this is
strictly a development-runner debugging step — leave mutating actions for
the cloud path in step 6.
- Prove the bounds hold: rerun with an out-of-range value — a path outside
allowed_prefixes, an oversized string, a number past max — and require
a validation rejection, not an execution. An action whose denial you have
not seen is unproven.
emisar doctor — confirm the runner still reports healthy, with required
binaries present and env vars allowlisted.
5. Distribute it
A few hosts — install the directory, pinned. On each runner, install the
exact bytes you validated and reviewed; the install reloads the runner for you:
sudo emisar pack install ./my-pack --hash sha256:<validated hash>
Config management (Ansible, a base image) can drop the directory instead and
reload the runner. Either way every host runs identical, reviewed bytes.
A fleet — host a private registry. A registry is a static file tree over
HTTPS; anything that serves files can be one. It moves bytes only — the portal
still decides trust per account (step 6).
Get packctl on the publishing workstation or CI job (never fleet hosts).
Build it from the same signed release tag your runners were installed from,
so the tool that hashes a pack is the one whose loader will enforce that
hash. Do not reach for go install …@latest: it pins nothing and builds
whatever the default branch points at that minute.
git clone --depth 1 --branch runner-v<version> \
https://github.com/andrewdryga/emisar.git emisar-src
cd emisar-src/runner && go build -o ~/.local/bin/packctl ./cmd/packctl
Release tags are signed annotated tags, so git verify-tag runner-v<version>
confirms the checkout before you build if you hold the signing key. Requires
a Go toolchain; check packctl --version.
Build the tree. --base-url is wherever you will host it:
packctl catalog build --packs ./packs --out ./dist \
--base-url https://packs.acme.internal
Host it. GCS is native — immutable objects are precondition-protected and
the pointers flip last:
GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token) \
packctl catalog publish --dir ./dist --bucket acme-pack-registry
S3, MinIO, or nginx: sync the files yourself — immutable objects first,
then the two mutable pointers (v1/suggest.json, then v1/catalog.json)
so a reader never sees a catalog referencing bytes that are not there yet.
Install fleet-wide, still hash-pinned:
sudo emisar pack install my-pack \
--registry https://packs.acme.internal --hash sha256:<reviewed>
Or set EMISAR_PACKS_REGISTRY=https://packs.acme.internal once per host
and use plain pack names; my-pack=0.2.0 pins a version. emisar pack update --dry-run then reports fleet drift against your registry.
Every rebuild after the first publish carries history forward. Fetch the
currently-published catalog and pass it as --previous — this is what makes a
byte change to an already-published id@version fail the build (bump the
version instead) and keeps each pack's version history and retirement floor
intact. A rebuild without --previous silently starts history from empty:
curl -fsS https://packs.acme.internal/v1/catalog.json -o current.json
packctl catalog build --packs ./packs --out ./dist \
--base-url https://packs.acme.internal --previous current.json
6. Trust it, then certify end to end
Trust is per account, and one deployment setting decides where it comes from:
the portal trusts an exact pack@version/hash on sight when that tuple appears
in the catalog it is configured to read (EMISAR_PACK_CATALOG_URL), and holds
every other hash pending with dispatch held. On hosted Emisar that catalog is
Emisar's published one, which your custom pack is not in — so it lands on the
Packs page as pending. An account admin opens it, compares the content hash
against the pack validate output you recorded, reviews the actions, and
clicks Trust; from then on that exact byte-for-byte version is the only one
authorized. Publishing to a registry confers no trust by itself. On a self-hosted
deployment whose owner separately configured the portal to read that catalog,
the exact tuple may be trusted after the portal observes it. The deployment
owner chooses that catalog — never reconfigure it to skip a review, and verify
the resulting trust state through list_packs.
So verify which state your pack is actually in before certifying anything, then
prove the whole chain through the customer's real MCP client:
list_packs with include: "all" — the pack's exact version and
hash appear as executable, with no descriptor or deployment issues. That is
your trust verification: MCP does not expose pending, rejected, revoked, or
retirement-blocked refs, so an absent ref sends you back to the portal's
Packs page to see which one it is.
find_actions for the pack's job words — confirm the descriptions are
discoverable the way an operator would ask.
get_action for one action — the returned schema matches the authored
bounds, and the intended runner is listed compatible.
run_action with those exact refs, schema-valid args, and a clear reason,
for a risk: low read; follow wait_for_run to terminal success. For a
mutating action, dispatch through the same path and let policy and
approval apply — an approval prompt reaching the operator is the system
working; never work around it.
recent_runs — the run is attributed to this client, action, and runner,
and appears in the account audit log.
Lifecycle. Any pack change bumps version, re-validates, re-deploys, and
earns trust again — the hash changes, so the new tuple is pending until an
admin reviews it, or trusted after the portal's configured catalog carries and
the portal observes that exact tuple. That is the drift guard working, not a
fault. On a security or critical fix — an under-bounded arg, a secret-emitting
read, a path escape, or a mislabeled risk — a version bump alone leaves
vulnerable copies runnable: also set retired_below: <fixed version> in
pack.yaml so runners still advertising older versions fail closed at
dispatch. Registry publishes enforce that floor
monotonically (with --previous). Routine changes never retire — operators
update at their own pace.
Report
Use only these states: PASS, DEGRADED, FAIL, SKIPPED (name the
missing prerequisite and owner), UNSUPPORTED.
Pack report - <pack id>@<version> - <UTC timestamp>
Overall: PASS | DEGRADED | FAIL | NOT CERTIFIED
Check State Evidence
design review PASS actions, risk tiers, bounds decided and recorded
validate PASS pack <id> OK, sha256:<hash>
local proof PASS <action id> ran + out-of-bounds arg rejected
distribution PASS <hosts or registry URL, hash-pinned>
trust PASS <hash> trusted for the account (admin review | exact configured-catalog tuple)
MCP functional PASS <action, runner_ref, run_id, terminal status>
audit PASS run attributed in recent_runs
lifecycle PASS version/retirement plan recorded
Shipped: <pack id>@<version>, sha256:<hash>, <n> actions, risk ceiling <tier>
Open items: <owner + exact next action, or none>
Overall is PASS only when every applicable check passes, including verified
trust — an untrusted pack is authored, not shipped. A required FAIL makes it
FAIL; a required SKIPPED makes it NOT CERTIFIED. Include exact ids,
versions, hashes, refs, run IDs, and sanitized errors; never credential
values or unredacted output.
1---2name: author-pack3description: Author, validate, test, distribute, and certify a custom Emisar action pack for a customer fleet. Use when wrapping internal CLIs or services as declared actions, writing or reviewing pack/action YAML, testing a pack locally, rolling a pack out to runners, hosting a private pack registry with packctl, publishing a new pack version, retiring an unsafe version, or diagnosing pack trust and hash-mismatch issues.4---56# Author and ship a custom Emisar pack78Execute this workflow on the customer's environment. Do not require an Emisar9source checkout, fork, build toolchain, repository instructions, or internal10contributor skill. A pack is a directory of YAML the customer owns; author it11in their repo, prove it with the installed CLIs, and certify it through the12signed-in Emisar portal.1314A pack is the contract between the operator and the LLM: it declares exactly15what may run on hosts, with what arguments, at what risk. Treat every action16you author as attack surface. Author restrictively, prove each claim, and17return one evidence-backed report.1819## Use current public interfaces2021Default to the hosted control plane at `https://emisar.dev`. Use a different22`EMISAR_URL` only when the operator identifies and trusts that deployment.2324Verify commands and schemas before using them:2526- Use `emisar pack --help`, `emisar action --help`, and `packctl catalog27 --help` as the installed-version contracts.28- `emisar pack validate` is the schema authority — it runs the exact loader29 and hash path the runner enforces. When validation disagrees with any30 document, including this one, the validator wins.31- Use the public guides at `https://emisar.dev/docs/publishing-packs`32 (authoring), `https://emisar.dev/docs/action-packs` (full YAML reference),33 `https://emisar.dev/docs/pack-registry` (self-hosted registries), and34 `https://emisar.dev/docs/mcp-reference` (MCP catalog contract).35- Use the signed-in portal's **Packs** page for trust decisions and each36 runner's **Advertised actions** for what the fleet actually serves.37- Study installed packs as worked examples: `emisar pack install <name> --dest38 ./examples` fetches a public pack you can read; pick one whose shape matches39 the job (exec reads, script actions, credentialed services).4041Never reconstruct a YAML field, flag, or config shape from memory when the42validator, installed help, or a public reference can confirm it.4344## Safety rules4546- **The pack is a security boundary, not a convenience.** Every action you add47 is something an LLM may execute on hosts. When in doubt, narrow the action,48 raise its risk tier, or leave it out. Never loosen validation, lower a risk49 label, or widen `execution.inherit_env` to make a check pass.50- **Trust authority is a human decision, never yours.** Present the content51 hash, the diff, and your risk assessment; on hosted Emisar an account admin52 reviews the pending pack and clicks Trust in the portal. Do not press for53 approval, never click Trust, and never change which catalog the portal reads54 (step 6). Never call a pack trusted or certified until you have verified55 that exact hash is trusted for the account.56- **`emisar action run` executes locally and bypasses cloud policy and57 approvals.** Use it only for `risk: low` read actions, only on a58 development or staging runner, never on a production host. Prove mutating59 actions through the cloud path, where policy and approvals apply.60- Treat pack credentials, API tokens, and signing material as secrets. Secrets61 ride `execution.env` from the runner's protected environment file — never62 argv, never YAML literals, never stdout, never shell history. Mark secret63 args `sensitive: true`.64- Pack bytes are public to the fleet: hashed, advertised, and shown in the65 console. Nothing secret goes in a pack directory.66- If an action needs protected local files, sockets, groups, service control,67 or root, map it in `setup.host_access`. Name the exact actions, provide a68 persistent operator-run grant plus a verification command, and state the69 authority granted to the runner identity. Emisar displays these commands;70 it never runs them. Do not suggest action-time `sudo`: action children run71 with `no_new_privs` and cannot elevate that way.72- Keep publisher credentials off fleet hosts. `packctl` runs on a workstation73 or CI job; runners only ever fetch and verify.74- Pin installs with `--hash` everywhere past the first authoring host. Never75 install a pack whose content you have not validated or reviewed.7677## 1. Scope the pack7879Settle what the pack is for before writing YAML:8081- The job: which service, CLI, or procedure the operator wants the LLM to82 handle, and the smallest action set that covers it. Prefer a few precise83 actions over a wrapper for every subcommand.84- The fleet: which runners get it, and how many hosts. A handful of hosts85 installs directly from a directory; a large fleet or CI-driven rollout86 wants a private registry (step 5).87- The authoring host: a development or staging runner where local proof is88 safe. Record `emisar --version` and `emisar pack list` there.89- The pack id: pick one that is not taken by the public catalog90 (`curl -fsS https://emisar.dev/packs.json` lists public ids) so registry91 installs never resolve ambiguously. Keep the directory in the customer's92 own git repo.9394Collect required credentials for the wrapped service the same way the runner's95other packs do — named variables in the runner's protected environment file —96without echoing values.9798## 2. Design each action before YAML99100Decide these per action, and write them down — they become the YAML:101102- **One action, one job, a searchable description.** The MCP catalog is what103 an LLM keyword-matches; open read descriptions with the verb of the job104 (List, Show, Get, Tail, Check), and make `description` a real doc string.105 List every file, network, and process side effect under `side_effects`.106- **Risk is honest.** `low` is reserved for pure reads and cheap bounded107 probes — it runs without approval. Anything that mutates state, opens a108 listener, binds a port, or can saturate a link is at least `medium`;109 destructive operations are `high`; unrestricted escapes are `critical`.110 Mislabeling a mutating action `low` bypasses the operator's approval gate.111- **The LLM never controls the command.** `execution.command.binary` plus an112 `argv` list is the shape; `{{ args.x }}` substitutes into fixed slots. The113 binary is a bare PATH-resolved name (`systemctl`, `psql`). When an action114 genuinely needs a pipeline, `/bin/sh` with a fixed `-c '<script>'` you115 author is acceptable. Pass open-ended strings, paths, and arrays through116 `execution.env` or as whole positional elements after the program117 (`["-c", "grep -- \"$1\" file", "emisar", "{{ args.pattern }}"]`).118 Only finite `enum`/`allowed` choices and numbers with both `min` and `max`119 may render into program text. An anchored regex constrains value shape; it120 is not a shell-isolation boundary.121- **Bound every argument.** Strings get `max_length` and an anchored122 `pattern` or an `enum`; numbers get `min`/`max`. An unbounded string is a123 DoS hole. **An anchored pattern is not path containment**: `.` and `/` are124 ordinary characters, so `^/var/log/myapp/.*` still matches125 `/var/log/myapp/../../etc/shadow`. A path the command reads or writes must126 declare a nonempty `allowed_prefixes` or `allowed_paths` — those are the127 only fields that confine it to a location. `denied_prefixes`/`denied_paths`128 are optional extra exclusions, not containment: any of the four turns on the129 runner's symlink-resolving canonical pass, but a deny-only rule refuses the130 paths it names and accepts every other absolute path.131- **A private pack may hardcode the fleet.** Unlike generic public packs,132 yours can enum the exact unit names, databases, and hosts it operates —133 tighter than any pattern. Use that advantage.134- **Design actions not to emit secrets.** No environment dumps, no135 credential-bearing connection strings, no unfiltered config reads.136 `output.redact` rules scrub known shapes as a fail-closed last line, but a137 secret no rule matches leaks — so don't print them in the first place.138139## 3. Author the pack140141The layout, from the authoring guide:142143```text144my-pack/145 pack.yaml # manifest: metadata + which action files to load146 actions/*.yaml # one action per file147 scripts/*.sh # only for kind: script actions148```149150`pack.yaml` declares the pack; new packs start at `version: 0.1.0`:151152```yaml153schema_version: 1154id: my-pack155name: My ops pack156version: 0.1.0157description: Short one-line summary shown on the runner and console.158vendor: acme159requires:160 os: [linux]161 binaries: [journalctl]162setup:163 host_access:164 - actions: [my.journal_tail]165 requirement: Read the system journal.166 recipes:167 - name: systemd Linux — default emisar service user168 commands:169 - sudo usermod -aG systemd-journal emisar170 - sudo systemctl restart emisar171 verify:172 - sudo -u emisar journalctl -n 1 --no-pager173 impact: The emisar service identity can read the complete system journal.174actions:175 - actions/journal_tail.yaml176```177178Each action file is the full contract — this example shows the load-bearing179fields; the complete schema is in the YAML reference and the validator:180181```yaml182schema_version: 1183id: my.journal_tail184title: Read recent system journal entries185kind: exec186risk: low187description: >188 Show the most recent system journal entries.189side_effects:190 - Reads the system journal.191 - Touches nothing.192args:193 - name: lines194 type: integer195 default: 100196 validation: { min: 1, max: 1000 }197execution:198 command:199 binary: journalctl200 argv: ["--no-pager", "-n", "{{ args.lines }}"]201 timeout: 10s202output:203 parser: text204 max_stdout_bytes: 65536205 max_stderr_bytes: 8192206examples:207 - title: Read the last 100 entries208 args: { lines: 100 }209```210211Write every action this deliberately: designed bounds from step 2, honest212risk, honest side effects, an example an operator would recognize.213214## 4. Validate, then prove it locally215216On the authoring runner:2172181. `emisar pack validate ./my-pack` — the same checks the runner runs at219 load. Fix until it reports `pack <id> OK` and record the printed `sha256:`220 content hash; that exact tuple is what the portal catalog matches or an221 account admin reviews.2222. `sudo emisar pack install ./my-pack` — copies it into the runner's packs223 dir and reloads the running daemon itself (no restart, no dropped runs).2243. `emisar action list` and `emisar action describe <id>` — confirm every225 action loaded with the intended risk, args, and bounds.2264. Prove one `risk: low` read locally: `emisar action run <id> --arg k=v227 --reason "pack authoring check"`. Local runs bypass the cloud, so this is228 strictly a development-runner debugging step — leave mutating actions for229 the cloud path in step 6.2305. Prove the bounds hold: rerun with an out-of-range value — a path outside231 `allowed_prefixes`, an oversized string, a number past `max` — and require232 a validation rejection, not an execution. An action whose denial you have233 not seen is unproven.2346. `emisar doctor` — confirm the runner still reports healthy, with required235 binaries present and env vars allowlisted.236237## 5. Distribute it238239**A few hosts — install the directory, pinned.** On each runner, install the240exact bytes you validated and reviewed; the install reloads the runner for you:241242```sh243sudo emisar pack install ./my-pack --hash sha256:<validated hash>244```245246Config management (Ansible, a base image) can drop the directory instead and247reload the runner. Either way every host runs identical, reviewed bytes.248249**A fleet — host a private registry.** A registry is a static file tree over250HTTPS; anything that serves files can be one. It moves bytes only — the portal251still decides trust per account (step 6).2522531. Get `packctl` on the publishing workstation or CI job (never fleet hosts).254 Build it from the same signed release tag your runners were installed from,255 so the tool that hashes a pack is the one whose loader will enforce that256 hash. Do not reach for `go install …@latest`: it pins nothing and builds257 whatever the default branch points at that minute.258259 ```sh260 git clone --depth 1 --branch runner-v<version> \261 https://github.com/andrewdryga/emisar.git emisar-src262 cd emisar-src/runner && go build -o ~/.local/bin/packctl ./cmd/packctl263 ```264265 Release tags are signed annotated tags, so `git verify-tag runner-v<version>`266 confirms the checkout before you build if you hold the signing key. Requires267 a Go toolchain; check `packctl --version`.2682. Build the tree. `--base-url` is wherever you will host it:269270 ```sh271 packctl catalog build --packs ./packs --out ./dist \272 --base-url https://packs.acme.internal273 ```2742753. Host it. GCS is native — immutable objects are precondition-protected and276 the pointers flip last:277278 ```sh279 GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token) \280 packctl catalog publish --dir ./dist --bucket acme-pack-registry281 ```282283 S3, MinIO, or nginx: sync the files yourself — immutable objects first,284 then the two mutable pointers (`v1/suggest.json`, then `v1/catalog.json`)285 so a reader never sees a catalog referencing bytes that are not there yet.2864. Install fleet-wide, still hash-pinned:287288 ```sh289 sudo emisar pack install my-pack \290 --registry https://packs.acme.internal --hash sha256:<reviewed>291 ```292293 Or set `EMISAR_PACKS_REGISTRY=https://packs.acme.internal` once per host294 and use plain pack names; `my-pack=0.2.0` pins a version. `emisar pack295 update --dry-run` then reports fleet drift against your registry.296297**Every rebuild after the first publish carries history forward.** Fetch the298currently-published catalog and pass it as `--previous` — this is what makes a299byte change to an already-published `id@version` fail the build (bump the300version instead) and keeps each pack's version history and retirement floor301intact. A rebuild without `--previous` silently starts history from empty:302303```sh304curl -fsS https://packs.acme.internal/v1/catalog.json -o current.json305packctl catalog build --packs ./packs --out ./dist \306 --base-url https://packs.acme.internal --previous current.json307```308309## 6. Trust it, then certify end to end310311Trust is per account, and one deployment setting decides where it comes from:312the portal trusts an exact `pack@version/hash` on sight when that tuple appears313in the catalog it is configured to read (`EMISAR_PACK_CATALOG_URL`), and holds314every other hash pending with dispatch held. On hosted Emisar that catalog is315Emisar's published one, which your custom pack is not in — so it lands on the316**Packs** page as pending. An account admin opens it, compares the content hash317against the `pack validate` output you recorded, reviews the actions, and318clicks Trust; from then on that exact byte-for-byte version is the only one319authorized. Publishing to a registry confers no trust by itself. On a self-hosted320deployment whose owner separately configured the portal to read that catalog,321the exact tuple may be trusted after the portal observes it. The deployment322owner chooses that catalog — never reconfigure it to skip a review, and verify323the resulting trust state through `list_packs`.324325So verify which state your pack is actually in before certifying anything, then326prove the whole chain through the customer's real MCP client:3273281. `list_packs` with `include: "all"` — the pack's exact version and329 hash appear as executable, with no descriptor or deployment issues. That is330 your trust verification: MCP does not expose pending, rejected, revoked, or331 retirement-blocked refs, so an absent ref sends you back to the portal's332 **Packs** page to see which one it is.3332. `find_actions` for the pack's job words — confirm the descriptions are334 discoverable the way an operator would ask.3353. `get_action` for one action — the returned schema matches the authored336 bounds, and the intended runner is listed compatible.3374. `run_action` with those exact refs, schema-valid args, and a clear reason,338 for a `risk: low` read; follow `wait_for_run` to terminal success. For a339 mutating action, dispatch through the same path and let policy and340 approval apply — an approval prompt reaching the operator is the system341 working; never work around it.3425. `recent_runs` — the run is attributed to this client, action, and runner,343 and appears in the account audit log.344345**Lifecycle.** Any pack change bumps `version`, re-validates, re-deploys, and346earns trust again — the hash changes, so the new tuple is pending until an347admin reviews it, or trusted after the portal's configured catalog carries and348the portal observes that exact tuple. That is the drift guard working, not a349fault. On a security or critical fix — an under-bounded arg, a secret-emitting350read, a path escape, or a mislabeled risk — a version bump alone leaves351vulnerable copies runnable: also set `retired_below: <fixed version>` in352`pack.yaml` so runners still advertising older versions fail closed at353dispatch. Registry publishes enforce that floor354monotonically (with `--previous`). Routine changes never retire — operators355update at their own pace.356357## Report358359Use only these states: `PASS`, `DEGRADED`, `FAIL`, `SKIPPED` (name the360missing prerequisite and owner), `UNSUPPORTED`.361362```text363Pack report - <pack id>@<version> - <UTC timestamp>364Overall: PASS | DEGRADED | FAIL | NOT CERTIFIED365366Check State Evidence367design review PASS actions, risk tiers, bounds decided and recorded368validate PASS pack <id> OK, sha256:<hash>369local proof PASS <action id> ran + out-of-bounds arg rejected370distribution PASS <hosts or registry URL, hash-pinned>371trust PASS <hash> trusted for the account (admin review | exact configured-catalog tuple)372MCP functional PASS <action, runner_ref, run_id, terminal status>373audit PASS run attributed in recent_runs374lifecycle PASS version/retirement plan recorded375376Shipped: <pack id>@<version>, sha256:<hash>, <n> actions, risk ceiling <tier>377Open items: <owner + exact next action, or none>378```379380Overall is `PASS` only when every applicable check passes, including verified381trust — an untrusted pack is authored, not shipped. A required `FAIL` makes it382`FAIL`; a required `SKIPPED` makes it `NOT CERTIFIED`. Include exact ids,383versions, hashes, refs, run IDs, and sanitized errors; never credential384values or unredacted output.