誠 Makoto
Menu
JavaScript binding

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.

The code this page is about. Repeated from 2. The complete module below.
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 614eb137303b

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 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@8

2. 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.mjs

One 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 614eb137303b

3. 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 errorClass and message.
3 · internal error
Same stderr shape with errorClass set to internal. 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 ajv entry targets draft-07. Import ajv/dist/2020.js instead.
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 loadSchema function and use compileAsync, 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. execFile reports the same condition as ERR_CHILD_PROCESS_STDIO_MAXBUFFER. The example raises maxBuffer to 64 MB; the report’s own reportTruncated flag tells you when the verifier itself capped detail.
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