誠 Makoto
Menu
Bash binding

Make the shell fail closed.

In a pipeline step there is no object model, only an exit status and a JSON document. Call the reference verifier directly. This page builds one script that validates structure, signs an origin statement, verifies a bundle, branches on the verdict, and reads the report with jq, then wires the same gate into a CI job.

The code this page is about. Repeated from 5. Verify and branch on the verdict below.
# 3. Verify: exit 0 allow, 1 deny or indeterminate, 2 bad input, 3 internal.
verify() {
  makoto verify bundle "$1" \
    --policy "$DEMO/receiver/policy.json" \
    --schema-catalog "$DEMO/receiver/catalog.json" \
    --expected-manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 \
    --expected-head sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce \
    --expected-artifact "$DEMO/receiver/expected-artifact.json" \
    --evaluation-time 2026-09-16T16:00:00Z \
    --json > "$2"
}

gate() {
  local status=0
  verify "$1" "$2" || status=$?
  case "$status" in
    0) jq -r '"allow: \(.summary.signaturesValid)/\(.summary.signaturesTotal) signatures valid"' "$2" ;;
    1) jq -r '"refused: \(.decision) \(.primaryError)",
              (.errors[] | "  step \(.step) \(.code): \(.message)")' "$2" >&2 ;;
    *) echo "verifier could not run (exit $status); this is not a verdict" >&2 ;;
  esac
  return "$status"
}

What you are binding to. Makoto ships as source: a Python reference library and the makoto command-line interface in the core repository, pinned here to revision 276008eeec0f. There is no published package on PyPI, npm, or the Go module proxy, and the PyPI name makoto belongs to an unrelated project. Every binding on these pages therefore drives the pinned reference verifier and reads its JSON report. That is the supported automation contract: exit status plus the report, never scraped human wording.

1. Install the verifier and the fixtures

git clone https://github.com/makoto-project/makoto.git
cd makoto
git checkout 276008eeec0f10d5322c4c627b8c103222fee841
uv sync --locked --dev

# Writes the deterministic fixture set: one allowed bundle, seven denials.
./scripts/demo.sh --acceptance

export MAKOTO_CORE="$PWD"

The script also uses jq and runs check-jsonschema through uvx, so nothing else needs installing.

2. The complete script

Save this as makoto-gate.sh and run it with bash.

#!/usr/bin/env bash
# Validate, attest, verify, and gate on a Makoto report from a pipeline step.
set -euo pipefail

: "${MAKOTO_CORE:?set MAKOTO_CORE to a reviewed, synced core checkout}"
CORE="$(cd "$MAKOTO_CORE" && pwd)"
DEMO="$CORE/demos/end-to-end/generated"
WORK="$(mktemp -d)"

makoto() { (cd "$CORE" && uv run --quiet makoto "$@"); }

# 1. Structure: cheap, keyless, and not a verdict.
uvx --quiet check-jsonschema \
  --schemafile https://usemakoto.dev/schema/v0.2/trust-policy.schema.json \
  "$DEMO/receiver/policy.json"

# 2. Produce: a key pair and a signed origin statement for new data.
printf 'order_id,total\n1001,42.00\n' > "$WORK/orders.csv"
makoto key generate \
  --private-out "$WORK/producer.private.pem" \
  --public-out "$WORK/producer.public.pem"
makoto attest origin \
  --subject "orders.csv=$WORK/orders.csv" \
  --source-kind urn:example:source:nightly-export \
  --source-uri urn:example:orders:2026-09-16 \
  --key "$WORK/producer.private.pem" \
  --out "$WORK/origin.dsse.json"
makoto envelope inspect --envelope "$WORK/origin.dsse.json" --json | jq .

# 3. Verify: exit 0 allow, 1 deny or indeterminate, 2 bad input, 3 internal.
verify() {
  makoto verify bundle "$1" \
    --policy "$DEMO/receiver/policy.json" \
    --schema-catalog "$DEMO/receiver/catalog.json" \
    --expected-manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 \
    --expected-head sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce \
    --expected-artifact "$DEMO/receiver/expected-artifact.json" \
    --evaluation-time 2026-09-16T16:00:00Z \
    --json > "$2"
}

gate() {
  local status=0
  verify "$1" "$2" || status=$?
  case "$status" in
    0) jq -r '"allow: \(.summary.signaturesValid)/\(.summary.signaturesTotal) signatures valid"' "$2" ;;
    1) jq -r '"refused: \(.decision) \(.primaryError)",
              (.errors[] | "  step \(.step) \(.code): \(.message)")' "$2" >&2 ;;
    *) echo "verifier could not run (exit $status); this is not a verdict" >&2 ;;
  esac
  return "$status"
}

# 4. The untouched bundle is allowed.
gate "$DEMO/positive-bundle" "$WORK/positive.json"

# 5. Change one byte of the delivered data and the gate must refuse it.
cp -R "$DEMO/positive-bundle" "$WORK/tampered-bundle"
printf '\n' >> "$(ls "$WORK"/tampered-bundle/artifacts/final/*.bin)"
if gate "$WORK/tampered-bundle" "$WORK/tampered.json"; then
  echo "tampered bundle was accepted" >&2
  exit 1
fi

# 6. Read the report: what failed, and what was never established.
jq '{decision, primaryError,
     notPassed: [.checks[] | select(.status != "pass") | {id, status, prerequisiteChecks}],
     policyDigest: .policyDigest.sha256}' "$WORK/tampered.json"
MAKOTO_CORE=/path/to/makoto bash makoto-gate.sh

One run’s output, unedited. Key IDs, payload digests, and temporary paths differ on every run; the decisions, codes, and policy digest do not.

ok -- validation done
{
  "payloadDigest": {
    "sha256": "f21bc824c7f7767ff75db8a9868adb2fe43c40521ba3256e576d7c40fa8733d6"
  },
  "payloadType": "application/vnd.in-toto+json",
  "signatureKeyIds": [
    "sha256:b1ab7da225b0d874043317a49943e03a1a6d087c7e1f4534e096e7fe57697adc"
  ]
}
allow: 4/4 signatures valid
refused: deny E_ARTIFACT_DIGEST
  step 12 E_ARTIFACT_DIGEST: final artifact bytes differ from the signed digest
{
  "decision": "deny",
  "primaryError": "E_ARTIFACT_DIGEST",
  "notPassed": [
    {
      "id": "artifact-bytes",
      "status": "fail",
      "prerequisiteChecks": []
    }
  ],
  "policyDigest": "614eb137303bcea78f25ded2e332a10649358484f9582984c1730ddc106ebfcd"
}

3. Validate structure

A structural check needs no keys and no checkout, which makes it the right pre-flight at the edge of a pipeline. It says nothing about who signed what.

# 1. Structure: cheap, keyless, and not a verdict.
uvx --quiet check-jsonschema \
  --schemafile https://usemakoto.dev/schema/v0.2/trust-policy.schema.json \
  "$DEMO/receiver/policy.json"

4. Generate an attestation

--subject takes name=path: the name is what later steps and bindings refer to, and the digest is computed from the bytes at the path.

# 2. Produce: a key pair and a signed origin statement for new data.
printf 'order_id,total\n1001,42.00\n' > "$WORK/orders.csv"
makoto key generate \
  --private-out "$WORK/producer.private.pem" \
  --public-out "$WORK/producer.public.pem"
makoto attest origin \
  --subject "orders.csv=$WORK/orders.csv" \
  --source-kind urn:example:source:nightly-export \
  --source-uri urn:example:orders:2026-09-16 \
  --key "$WORK/producer.private.pem" \
  --out "$WORK/origin.dsse.json"
makoto envelope inspect --envelope "$WORK/origin.dsse.json" --json | jq .

For a derived output, makoto attest transform takes --operation-type and one --input-binding JSON file per input with name, path, predecessor, and subjectName. The runnable walkthrough shows a three-step chain and its handoff.

5. Verify and branch on the verdict

Where the expected digests come from. The manifest digest, head digest, and expected-artifact file are the receiver’s independent anchors. The fixture policy sets requireExpectedManifest, requireExpectedHead, and requireExpectedArtifacts, so leaving them out is a denial, not a shortcut. In production they arrive through a channel the producer’s bundle cannot rewrite: a change ticket, a signed release note, a registry entry. The values below are the fixture’s published digests.

# 3. Verify: exit 0 allow, 1 deny or indeterminate, 2 bad input, 3 internal.
verify() {
  makoto verify bundle "$1" \
    --policy "$DEMO/receiver/policy.json" \
    --schema-catalog "$DEMO/receiver/catalog.json" \
    --expected-manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 \
    --expected-head sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce \
    --expected-artifact "$DEMO/receiver/expected-artifact.json" \
    --evaluation-time 2026-09-16T16:00:00Z \
    --json > "$2"
}

gate() {
  local status=0
  verify "$1" "$2" || status=$?
  case "$status" in
    0) jq -r '"allow: \(.summary.signaturesValid)/\(.summary.signaturesTotal) signatures valid"' "$2" ;;
    1) jq -r '"refused: \(.decision) \(.primaryError)",
              (.errors[] | "  step \(.step) \(.code): \(.message)")' "$2" >&2 ;;
    *) echo "verifier could not run (exit $status); this is not a verdict" >&2 ;;
  esac
  return "$status"
}

Then the two outcomes, including a tampered copy the gate must refuse:

# 4. The untouched bundle is allowed.
gate "$DEMO/positive-bundle" "$WORK/positive.json"

# 5. Change one byte of the delivered data and the gate must refuse it.
cp -R "$DEMO/positive-bundle" "$WORK/tampered-bundle"
printf '\n' >> "$(ls "$WORK"/tampered-bundle/artifacts/final/*.bin)"
if gate "$WORK/tampered-bundle" "$WORK/tampered.json"; then
  echo "tampered bundle was accepted" >&2
  exit 1
fi

6. Read the verification report

List what did not pass and keep the policy digest. A skipped entry with a non-empty prerequisiteChecks means the check was never tested.

# 6. Read the report: what failed, and what was never established.
jq '{decision, primaryError,
     notPassed: [.checks[] | select(.status != "pass") | {id, status, prerequisiteChecks}],
     policyDigest: .policyDigest.sha256}' "$WORK/tampered.json"
# Every error code, by step, from a stored report:
jq -r '.errors[] | "\(.step)\t\(.code)\t\(.causedByCheck)"' report.json

# Fail a job unless every check passed (stricter than the decision alone):
jq -e 'all(.checks[]; .status == "pass")' report.json > /dev/null

Exit status contract

0 · allow
Every required check passed under this exact policy and expectation set. stdout holds the report; keep it with the policy digest.
1 · deny or indeterminate
stdout still holds a complete report. Refuse the data and route on primaryError.
2 · invalid input
A missing file, a malformed flag, an unreadable policy. No report is written; stderr carries one JSON object with errorClass and message.
3 · internal error
Same stderr shape with errorClass set to internal. Treat it as an outage, never as a verdict.

In a GitHub Actions job

The job checks out the receiver’s repository and the pinned verifier side by side. The policy, catalog, and expected digests live in the receiver’s repository and variables, never in the incoming bundle. The report is uploaded even on a denial, because the report is the explanation. This workflow passes actionlint.

name: verify-incoming-handoff

on:
  workflow_dispatch:
    inputs:
      bundle-run-id:
        description: Run ID of the producer workflow that uploaded the bundle
        required: true

permissions:
  contents: read
  actions: read

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - name: Check out receiver policy and expectations
        uses: actions/checkout@v4
        with:
          path: receiver
          persist-credentials: false

      - name: Check out the pinned reference verifier
        uses: actions/checkout@v4
        with:
          repository: makoto-project/makoto
          ref: 276008eeec0f10d5322c4c627b8c103222fee841
          path: makoto-core
          persist-credentials: false

      - name: Install uv
        uses: astral-sh/setup-uv@v6

      - name: Sync the verifier environment
        working-directory: makoto-core
        run: uv sync --locked --dev

      - name: Download the incoming bundle
        uses: actions/download-artifact@v4
        with:
          name: handoff-bundle
          path: incoming/bundle
          run-id: ${{ inputs.bundle-run-id }}
          github-token: ${{ github.token }}

      - name: Call the reference verifier directly
        id: gate
        working-directory: makoto-core
        env:
          EXPECTED_MANIFEST: ${{ vars.MAKOTO_EXPECTED_MANIFEST }}
          EXPECTED_HEAD: ${{ vars.MAKOTO_EXPECTED_HEAD }}
        run: |
          set -euo pipefail
          status=0
          uv run makoto verify bundle "$GITHUB_WORKSPACE/incoming/bundle" \
            --policy "$GITHUB_WORKSPACE/receiver/makoto/policy.json" \
            --schema-catalog "$GITHUB_WORKSPACE/receiver/makoto/catalog.json" \
            --expected-manifest "$EXPECTED_MANIFEST" \
            --expected-head "$EXPECTED_HEAD" \
            --expected-artifact "$GITHUB_WORKSPACE/receiver/makoto/expected-artifact.json" \
            --json > "$RUNNER_TEMP/makoto-report.json" || status=$?
          if [[ "$status" -gt 1 ]]; then
            echo "::error::verifier could not run (exit $status)"
            exit "$status"
          fi
          jq -r '"decision=\(.decision) primaryError=\(.primaryError)"' \
            "$RUNNER_TEMP/makoto-report.json"
          jq -r '.errors[] | "::error::step \(.step) \(.code): \(.message)"' \
            "$RUNNER_TEMP/makoto-report.json"
          exit "$status"

      - name: Keep the report, allowed or not
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: makoto-report
          path: ${{ runner.temp }}/makoto-report.json
          if-no-files-found: warn

Troubleshooting

The job continues after a denial
Under set -e, a command whose status is tested by if, ||, or && does not abort the script. Capture the status explicitly, as gate does, and exit on anything but 0.
jq: error: Could not open file after a failed run
Exit statuses 2 and 3 write no report. Check the status before reading the file, and print stderr, which carries the errorClass JSON.
Every run denies with E_EXPECTED_MANIFEST, E_EXPECTED_HEAD, and E_EXPECTED_ARTIFACT at step 11
The expectation flags are missing or stale. The freshness-anchors check fails because the policy requires all three. Supply the digests the producer published for this handoff.
Exit status 2 with {"errorClass":"invalid-input",...} on stderr
An input path is wrong or unreadable, or a singleton flag was passed twice. Paths are resolved from the process working directory, so pass absolute paths when the verifier runs with cwd set to the core checkout.
The report shows skipped for a check you care about
An earlier check failed and did not establish its prerequisite. prerequisiteChecks names it. A skipped check was not tested; never read it as a pass.
uv: command not found inside a service or CI job
The binding needs uv on PATH for the process that spawns the verifier. Install it in the image or call the checkout’s .venv/bin/makoto directly after uv sync --locked.

The same gate in every language.

Each binding runs the same six steps against the same fixtures and produces the same decision. The report and exit status are the contract; the language is a wrapper.

Bindings: Bash · Python · JavaScript · TypeScript · Go