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.
# 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.shOne 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
fi6. 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/nullExit 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
errorClassandmessage. 3· internal error- Same stderr shape with
errorClassset tointernal. 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: warnTroubleshooting
- The job continues after a denial
- Under
set -e, a command whose status is tested byif,||, or&&does not abort the script. Capture the status explicitly, asgatedoes, andexiton anything but 0. jq: error: Could not open fileafter a failed run- Exit statuses 2 and 3 write no report. Check the status before reading the file, and print stderr, which carries the
errorClassJSON. - Every run denies with
E_EXPECTED_MANIFEST,E_EXPECTED_HEAD, andE_EXPECTED_ARTIFACTat step 11 - The expectation flags are missing or stale. The
freshness-anchorscheck 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
cwdset to the core checkout. - The report shows
skippedfor a check you care about - An earlier check failed and did not establish its prerequisite.
prerequisiteChecksnames it. A skipped check was not tested; never read it as a pass. uv: command not foundinside a service or CI job- The binding needs
uvonPATHfor the process that spawns the verifier. Install it in the image or call the checkout’s.venv/bin/makotodirectly afteruv 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