Gate a Node.js service on a Makoto verdict.
One complete ES module: compile the hosted trust-policy schema with AJV’s Draft 2020-12 build, sign an origin statement, spawn the pinned verifier, refuse a tampered bundle, and surface the primary error to the caller. It was executed on Node.js 26 against the pinned core before publication.
policy structure: valid
signed {
payloadDigest: {
sha256: 'd62472eee50d2816f5234f51ac7f3fb8aaafa1783c167138f92db8a4548d9eb7'
},
payloadType: 'application/vnd.in-toto+json',
signatureKeyIds: [
'sha256:32297b2f194c32d8782c367250245076e172646a8e9f7b7e21f5bc5816414584'
]
}
decision=allow primaryError=null
signatures 4/4 valid, policy 614eb137303b
decision=deny primaryError=E_ARTIFACT_DIGEST
check artifact-bytes: fail []
step 12 E_ARTIFACT_DIGEST: final artifact bytes differ from the signed digest
signatures 4/4 valid, policy 614eb137303bWhat 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 one dependency
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 binding needs a single npm package for structural validation. The verifier itself has no JavaScript dependency.
mkdir makoto-gate && cd makoto-gate
npm init -y && npm pkg set type=module
npm install ajv@82. The complete module
Save this as makoto-gate.mjs. The functions are exported so a service can import them; the bottom of the file runs the demonstration.
// Validate, attest, verify, and gate on a Makoto report from Node.js 20+.
import { spawnSync } from "node:child_process";
import { cpSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import Ajv2020 from "ajv/dist/2020.js";
const CORE = resolve(process.env.MAKOTO_CORE ?? "");
const DEMO = join(CORE, "demos/end-to-end/generated");
const SCHEMA_BASE = "https://usemakoto.dev/schema/v0.2/";
// Independent expectations: obtained out of band, never read from the bundle.
const EXPECTED_MANIFEST = "sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7";
const EXPECTED_HEAD = "sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce";
const EVALUATION_TIME = "2026-09-16T16:00:00Z";
export class MakotoToolError extends Error {}
export class MakotoDenied extends Error {
constructor(report) {
super(`${report.decision}: ${report.primaryError}`);
this.report = report;
}
}
function makoto(...args) {
const result = spawnSync("uv", ["run", "--quiet", "makoto", ...args], {
cwd: CORE,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.error) throw new MakotoToolError(`could not start uv: ${result.error.message}`);
return result;
}
export async function validateStructure(instancePath, schemaName) {
const response = await fetch(SCHEMA_BASE + schemaName);
if (!response.ok) throw new MakotoToolError(`schema fetch failed: ${response.status}`);
const ajv = new Ajv2020({ allErrors: true, strict: false });
const validate = ajv.compile(await response.json());
const instance = JSON.parse(readFileSync(instancePath, "utf8"));
return validate(instance)
? []
: validate.errors.map((e) => `${e.instancePath || "(root)"} ${e.message}`);
}
export function attestOrigin(artifactPath, workDir) {
const privateKey = join(workDir, "producer.private.pem");
const out = join(workDir, "origin.dsse.json");
for (const args of [
["key", "generate", "--private-out", privateKey, "--public-out", join(workDir, "producer.public.pem")],
[
"attest", "origin",
"--subject", `${basename(artifactPath)}=${artifactPath}`,
"--source-kind", "urn:example:source:nightly-export",
"--source-uri", "urn:example:orders:2026-09-16",
"--key", privateKey,
"--out", out,
],
]) {
const result = makoto(...args);
if (result.status !== 0) throw new MakotoToolError(result.stderr.trim());
}
return out;
}
export function verifyBundle(bundleDir) {
const result = makoto(
"verify", "bundle", bundleDir,
"--policy", join(DEMO, "receiver/policy.json"),
"--schema-catalog", join(DEMO, "receiver/catalog.json"),
"--expected-manifest", EXPECTED_MANIFEST,
"--expected-head", EXPECTED_HEAD,
"--expected-artifact", join(DEMO, "receiver/expected-artifact.json"),
"--evaluation-time", EVALUATION_TIME,
"--json",
);
// 0 = allow, 1 = deny or indeterminate, 2 = invalid input, 3 = internal error.
if (result.status !== 0 && result.status !== 1) {
throw new MakotoToolError(result.stderr.trim() || `exit ${result.status ?? result.signal}`);
}
let report;
try {
report = JSON.parse(result.stdout);
} catch (error) {
throw new MakotoToolError(`malformed report: ${error.message}`);
}
if ((result.status === 0) !== (report.decision === "allow")) {
throw new MakotoToolError("exit status and report decision disagree");
}
if (report.decision !== "allow") throw new MakotoDenied(report);
return report;
}
export function explain(report) {
console.log(`decision=${report.decision} primaryError=${report.primaryError}`);
for (const check of report.checks.filter((c) => c.status !== "pass")) {
console.log(` check ${check.id}: ${check.status}`, check.prerequisiteChecks);
}
for (const error of report.errors) {
console.log(` step ${error.step} ${error.code}: ${error.message}`);
}
const s = report.summary;
console.log(` signatures ${s.signaturesValid}/${s.signaturesTotal} valid, policy ${report.policyDigest.sha256.slice(0, 12)}`);
}
const problems = await validateStructure(join(DEMO, "receiver/policy.json"), "trust-policy.schema.json");
console.log("policy structure:", problems.length ? problems : "valid");
const work = mkdtempSync(join(tmpdir(), "makoto-"));
const artifact = join(work, "orders.csv");
writeFileSync(artifact, "order_id,total\n1001,42.00\n");
const envelope = attestOrigin(artifact, work);
console.log("signed", JSON.parse(makoto("envelope", "inspect", "--envelope", envelope, "--json").stdout));
explain(verifyBundle(join(DEMO, "positive-bundle")));
const tampered = join(work, "tampered-bundle");
cpSync(join(DEMO, "positive-bundle"), tampered, { recursive: true });
const finalDir = join(tampered, "artifacts/final");
const finalFile = join(finalDir, readdirSync(finalDir).find((name) => name.endsWith(".bin")));
writeFileSync(finalFile, readFileSync(finalFile, "utf8").replace("]", ",{}]"));
try {
verifyBundle(tampered);
console.error("tampered bundle was accepted");
process.exitCode = 1;
} catch (error) {
if (!(error instanceof MakotoDenied)) throw error;
explain(error.report);
process.exitCode = error.report.primaryError === "E_ARTIFACT_DIGEST" ? 0 : 1;
}MAKOTO_CORE=/path/to/makoto node makoto-gate.mjsOne run’s output, unedited. Key IDs, payload digests, and temporary paths differ on every run; the decisions, codes, and policy digest do not.
policy structure: valid
signed {
payloadDigest: {
sha256: 'd62472eee50d2816f5234f51ac7f3fb8aaafa1783c167138f92db8a4548d9eb7'
},
payloadType: 'application/vnd.in-toto+json',
signatureKeyIds: [
'sha256:32297b2f194c32d8782c367250245076e172646a8e9f7b7e21f5bc5816414584'
]
}
decision=allow primaryError=null
signatures 4/4 valid, policy 614eb137303b
decision=deny primaryError=E_ARTIFACT_DIGEST
check artifact-bytes: fail []
step 12 E_ARTIFACT_DIGEST: final artifact bytes differ from the signed digest
signatures 4/4 valid, policy 614eb137303b3. Validate structure with AJV
Fetch the hosted schema, compile it once, and reuse the compiled validator. allErrors reports every problem in one pass, which is what a person fixing a policy file wants.
export async function validateStructure(instancePath, schemaName) {
const response = await fetch(SCHEMA_BASE + schemaName);
if (!response.ok) throw new MakotoToolError(`schema fetch failed: ${response.status}`);
const ajv = new Ajv2020({ allErrors: true, strict: false });
const validate = ajv.compile(await response.json());
const instance = JSON.parse(readFileSync(instancePath, "utf8"));
return validate(instance)
? []
: validate.errors.map((e) => `${e.instancePath || "(root)"} ${e.message}`);
}Structure is not verification. The validator catalogue shows the same schemas in other tools.
4. Generate an attestation
The producer side is two CLI calls: an Ed25519 key pair, then a signed origin statement over the exact bytes of the new file. Pass arguments as an array, never through a shell string, so a file name can never become a flag.
export function attestOrigin(artifactPath, workDir) {
const privateKey = join(workDir, "producer.private.pem");
const out = join(workDir, "origin.dsse.json");
for (const args of [
["key", "generate", "--private-out", privateKey, "--public-out", join(workDir, "producer.public.pem")],
[
"attest", "origin",
"--subject", `${basename(artifactPath)}=${artifactPath}`,
"--source-kind", "urn:example:source:nightly-export",
"--source-uri", "urn:example:orders:2026-09-16",
"--key", privateKey,
"--out", out,
],
]) {
const result = makoto(...args);
if (result.status !== 0) throw new MakotoToolError(result.stderr.trim());
}
return out;
}5. Verify a bundle and handle a denial
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.
export class MakotoToolError extends Error {}
export class MakotoDenied extends Error {
constructor(report) {
super(`${report.decision}: ${report.primaryError}`);
this.report = report;
}
}export function verifyBundle(bundleDir) {
const result = makoto(
"verify", "bundle", bundleDir,
"--policy", join(DEMO, "receiver/policy.json"),
"--schema-catalog", join(DEMO, "receiver/catalog.json"),
"--expected-manifest", EXPECTED_MANIFEST,
"--expected-head", EXPECTED_HEAD,
"--expected-artifact", join(DEMO, "receiver/expected-artifact.json"),
"--evaluation-time", EVALUATION_TIME,
"--json",
);
// 0 = allow, 1 = deny or indeterminate, 2 = invalid input, 3 = internal error.
if (result.status !== 0 && result.status !== 1) {
throw new MakotoToolError(result.stderr.trim() || `exit ${result.status ?? result.signal}`);
}
let report;
try {
report = JSON.parse(result.stdout);
} catch (error) {
throw new MakotoToolError(`malformed report: ${error.message}`);
}
if ((result.status === 0) !== (report.decision === "allow")) {
throw new MakotoToolError("exit status and report decision disagree");
}
if (report.decision !== "allow") throw new MakotoDenied(report);
return report;
}spawnSync reports a launch failure in result.error and a signal in result.signal with a null status; both are tool errors. A denial is an ordinary return with status 1 and a full report, turned into a typed MakotoDenied the caller can catch:
const tampered = join(work, "tampered-bundle");
cpSync(join(DEMO, "positive-bundle"), tampered, { recursive: true });
const finalDir = join(tampered, "artifacts/final");
const finalFile = join(finalDir, readdirSync(finalDir).find((name) => name.endsWith(".bin")));
writeFileSync(finalFile, readFileSync(finalFile, "utf8").replace("]", ",{}]"));
try {
verifyBundle(tampered);
console.error("tampered bundle was accepted");
process.exitCode = 1;
} catch (error) {
if (!(error instanceof MakotoDenied)) throw error;
explain(error.report);
process.exitCode = error.report.primaryError === "E_ARTIFACT_DIGEST" ? 0 : 1;
}6. Read the verification report
Route on primaryError, show the checks that did not pass, and keep the policy digest with anything you store.
export function explain(report) {
console.log(`decision=${report.decision} primaryError=${report.primaryError}`);
for (const check of report.checks.filter((c) => c.status !== "pass")) {
console.log(` check ${check.id}: ${check.status}`, check.prerequisiteChecks);
}
for (const error of report.errors) {
console.log(` step ${error.step} ${error.code}: ${error.message}`);
}
const s = report.summary;
console.log(` signatures ${s.signaturesValid}/${s.signaturesTotal} valid, policy ${report.policyDigest.sha256.slice(0, 12)}`);
}For declared types over the same report, see the TypeScript binding.
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
errorClassandmessage. 3· internal error- Same stderr shape with
errorClassset tointernal. Treat it as an outage, never as a verdict.
Inside a request handler
spawnSync blocks the event loop for the length of a verification, a few hundred milliseconds for the fixture. In an HTTP service, run the same arguments through the promise form of execFile so other requests keep moving, and keep the exit-status rules identical:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export async function verifyBundleAsync(core, args) {
try {
const { stdout } = await execFileAsync("uv", ["run", "--quiet", "makoto", ...args], {
cwd: core,
maxBuffer: 64 * 1024 * 1024,
});
return JSON.parse(stdout); // exit 0: allow
} catch (error) {
if (error.code === 1 && error.stdout) return JSON.parse(error.stdout); // deny, full report
throw new Error(`makoto could not run: ${error.stderr || error.message}`);
}
}Troubleshooting
no schema with key or ref "https://json-schema.org/draft/2020-12/schema"- The hosted schemas are Draft 2020-12, and the default
ajventry targets draft-07. Importajv/dist/2020.jsinstead. can't resolve reference https://usemakoto.dev/schema/v0.2/...- The trust-policy, envelope, and report schemas are self-contained, but the statement, origin, and transform schemas reference sibling schemas by URL. Pass a
loadSchemafunction and usecompileAsync, as shown in the validator catalogue. spawnSync uv ENOBUFS- A large bundle can produce a report bigger than Node’s 1 MB default output buffer.
execFilereports the same condition asERR_CHILD_PROCESS_STDIO_MAXBUFFER. The example raisesmaxBufferto 64 MB; the report’s ownreportTruncatedflag tells you when the verifier itself capped detail. - 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