DEV Community

Morgan Sun
Morgan Sun

Posted on

Allowlist the Command Before the Generated Runbook Ships

A support page for a stuck queue worker reached review in a composite scenario, not a customer report. The draft read cleanly. It promised recovery within five minutes, named a restart command, and assigned no owner.

That is the failure this workflow targets. Fluent prose is not a binding. A binding is a claim a person will stand behind after the page is published.

What breaks when draft and binding share one file

Generated troubleshooting pages mix two kinds of text. Descriptive lines are cheap to regenerate. Commitments change what on-call will run, or what a reader is told.

Reviewers who read for tone miss the second kind. The page looks finished because the sentences are finished. The operational commitments are not.

Three unsigned items cause most of the damage in this pattern. A time promise has no measurement behind it. A shell command was never added to an approved set. An escalation owner is left as "the team" or "TBD."

None of those require a new model. They require a split, then a check that refuses to publish when the split is violated.

What the model may draft

Treat model output as a proposal bundle. It can save a first-pass hour, and it is still unsigned. If a sentence would change an action or a promise, it does not belong in that bundle.

The draft lane may include:

  1. A symptom summary, marked source: model and claim_type: description.
  2. Candidate checks, each with approval: pending and no production command.
  3. A glossary line that cites an existing doc id, instead of inventing a definition.
  4. A suggested outline: detect, contain, verify, escalate.
  5. Synthetic log lines, labeled synthetic: true, so they are not pasted into an incident channel as evidence.

The model may not select severity, customer impact, or the command production is allowed to run. Those fields stay empty until a human writes them in a separate file.

What a human must own

A named person owns the binding ledger. A bot identity does not. The author of the prompt does not, unless that same person reviews the ledger under a normal change process.

Binding Human owns it because Reject if
severity It pages people and sets tempo Empty, or any of todo, tbd, model
customer_promise It is an external commitment A time window with no verifier
escalation_owner Someone must answer when a step fails A nickname with no individual id
environment_scope Commands are not universal Missing, or all without an exception list
verified_by / verified_at Freshness is a recorded fact Absent, or older than your own policy window
command + approval Execution is the hazardous part Approval is not human-approved, or the string is off the allowlist

The human does not have to write the symptom paragraph. They do have to refuse publication until the table is complete. That is a smaller job than rewriting the page, and it is the job that stops a confident overclaim.

A checker you can run before publish

The files below are a proposal. They were not executed against a production repository for this article. Copy them into a scratch directory if you want a local result.

runbook.json:

{
  "title": "Queue worker stuck in draining",
  "bindings": {
    "severity": "sev-3",
    "customer_promise": "none — internal only",
    "escalation_owner": "oncall-queue-primary",
    "environment_scope": "staging",
    "verified_by": "docs-owner",
    "verified_at": "2026-10-13T00:00:00Z"
  },
  "steps": [
    {
      "id": "s1",
      "source": "model",
      "claim_type": "description",
      "approval": "pending",
      "text": "Workers may report draining when the consumer loop stops fetching."
    },
    {
      "id": "s2",
      "source": "human",
      "claim_type": "check",
      "approval": "human-approved",
      "command": "systemctl is-active queue-worker"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

allowlist.json:

{
  "commands": [
    "systemctl is-active queue-worker",
    "curl -fsS http://127.0.0.1:8080/health"
  ]
}
Enter fullscreen mode Exit fullscreen mode

check_runbook.py:

#!/usr/bin/env python3
"""Proposal checker: unsigned bindings and off-allowlist commands fail the run."""

from __future__ import annotations

import json
import sys
from pathlib import Path

OWNED = (
    "severity",
    "customer_promise",
    "escalation_owner",
    "environment_scope",
    "verified_by",
    "verified_at",
)
PLACEHOLDERS = {"", "todo", "tbd", "unknown", "model", "n/a"}


def load(path: Path) -> dict:
    return json.loads(path.read_text(encoding="utf-8"))


def check(doc: dict, allow: dict) -> list[str]:
    errors: list[str] = []
    bindings = doc.get("bindings") or {}
    for key in OWNED:
        value = bindings.get(key)
        text = value.strip().lower() if isinstance(value, str) else ""
        if text in PLACEHOLDERS:
            errors.append(f"missing human binding: {key}")
    allowed = set(allow.get("commands") or [])
    for step in doc.get("steps") or []:
        command = step.get("command")
        if not command:
            if step.get("claim_type") == "promise" and step.get("source") == "model":
                errors.append(f"model authored a promise in {step.get('id')}")
            continue
        if step.get("approval") != "human-approved":
            errors.append(f"command not human-approved: {command}")
        if command not in allowed:
            errors.append(f"command outside allowlist: {command}")
    return errors


def main() -> int:
    if len(sys.argv) != 3:
        print("usage: check_runbook.py RUNBOOK.json ALLOWLIST.json", file=sys.stderr)
        return 2
    errors = check(load(Path(sys.argv[1])), load(Path(sys.argv[2])))
    if errors:
        print("\n".join(errors))
        return 1
    print("bindings present; commands allowlisted")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run the proposal checker with:

python3 check_runbook.py runbook.json allowlist.json
Enter fullscreen mode Exit fullscreen mode

A passing run prints one line and exits 0. Change approval on s2 to pending, or point command at any string that is not on the allowlist, and the process should exit 1. The checker does not debate whether a command is clever. It only asks whether a human already named that exact string.

How to wire the split

Keep generation and ownership in separate files. Mixing them makes review look like copy editing, which is how unsigned promises survive.

  1. Generate or hand-write draft.md with symptom text only. No severity, no recovery window, and no shell text that is not already on the allowlist.
  2. Store bindings in runbook.json. A human edits that file. The generation step does not get write access to it.
  3. Keep allowlist.json in the repository that owns production change control. Updates use the same review as other operational changes.
  4. Run check_runbook.py on every documentation pull request. Fail the job when the exit code is 1.
  5. Publish the rendered page only from a commit where the checker passed, and where verified_by matches the code owner for that path.

If a step needs a new command, stop and add that command to the allowlist in a reviewed change first. Then reference it. Do not let a draft introduce execution text "just for the example."

Where a free draft lane fits

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The checker does not need a model. The draft lane does, if you want proposal wording without spending the human hour on a blank page. Operator-supplied availability for that lane is free model access plus a free server option: enough, as stated, to draft draft.md and run this Python check in one scratch place. This article does not name models, quotas, hardware, or a time window. Those details are not established here, and they should be rechecked before anyone depends on them. Use the server as a scratch runner for the checker, not as a production control plane.

Remove the product and the ledger still stands. The split is the method. The host is optional.

A short test plan

Use this plan before you trust the script in CI. It is a plan, not a recorded lab result, and it does not prove that a passing page is operationally correct.

  • Fixture A: the sample JSON above. Expect exit 0.
  • Fixture B: set customer_promise to tbd. Expect exit 1 and a missing-binding line.
  • Fixture C: set s2.approval to pending. Expect a not-approved line.
  • Fixture D: add a step whose command is curl -fsS http://127.0.0.1:8080/health. Expect exit 0, because that exact string is allowlisted.
  • Fixture E: add systemctl restart queue-worker even with human-approved. Expect exit 1 until the allowlist changes in review.
  • Fixture F: a model step with claim_type set to promise and no command. Expect exit 1.

Record the six exit codes in the pull request. Do not record a model confidence score. The score is not a binding, and it should not sit next to verified_by.

Limitations

The script matches strings. It will not notice a promise hidden in text, such as a recovery window written as ordinary prose, unless you add a separate phrase check. That extension is still not proof that the prose is true.

An allowlist can ratify a bad command. Human approval is a control, not a guarantee. If reviewers stamp the file without reading the command, the checker will faithfully ship the stamp.

verified_at is not compared with a clock in the sample. Add a maximum age in your own policy if you need one. Do not copy a freshness number from an article and treat it as your service objective.

Free access, if you use it, is not a capacity plan. No throughput, retention, or uptime figure is claimed here. A scratch run that passes today says nothing about next month's offer.

Who should skip this

Skip it if nobody can be named in verified_by. A ledger without an owner is a form with empty signature lines.

Skip it for live incident command. This is a pre-publish gate, not a console for an outage. During an incident, use the bridge process you already have.

Skip it for customer legal notices, security advisories, and any page that would include exploit detail or credential handling. Those need specialist review. A JSON allowlist is the wrong instrument, and this workflow must not be stretched into attack procedures.

Skip it if the goal is for a model to choose severity or invent a recovery time. That goal is the opposite of the split.

Close

Write the symptom in the draft. Put the promise, the owner, the scope, and the command in the ledger. Publish when the checker exits 0, not when the paragraph sounds done.

If you want a scratch place to try that draft lane before you attach it to CI, the free model access and free server option noted above are enough to start. Recheck what is actually offered on the day you try it, and keep the binding file under human review either way.

Top comments (0)