誠 Makoto
Menu
TypeScript binding

Type the report before your code trusts it.

The same gate as the JavaScript binding, with declared types for the decision, check identifiers, statuses, and diagnostics, and a guard that narrows JSON.parse output from unknown before any branch reads it. Both files pass tsc --strict and run directly on Node.js 26 with native type stripping.

The code this page is about. Repeated from 3. The complete program below.
policy structure: valid
signed /var/folders/22/0hyg7lbs2fb34hr0ryq3sl1m0000gn/T/makoto-W3KsF0/origin.dsse.json
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 the toolchain

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"
mkdir makoto-gate && cd makoto-gate
npm init -y && npm pkg set type=module
npm install ajv@8
npm install --save-dev typescript@5 @types/node

This tsconfig.json checks the sources without emitting. allowImportingTsExtensions and erasableSyntaxOnly keep the files runnable by Node’s type stripping, which does not rewrite import paths or support enums.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "skipLibCheck": true
  },
  "files": ["makoto-gate.ts", "makoto-report.ts"]
}

2. Declare the report contract

Save this as makoto-report.ts. It types the fields a gate reads, not every field the report carries; the authoritative shape is the verification-report schema. The check identifiers are the sixteen checks[].id values the verifier emits, in order.

// Types for the fields a gate reads from a Makoto verification report.
// Authoritative shape: https://usemakoto.dev/schema/v0.2/verification-report.schema.json

export type Decision = "allow" | "deny" | "indeterminate";
export type CheckStatus = "pass" | "fail" | "indeterminate" | "not_checked" | "skipped";
export type Digest = { sha256: string };

export type CheckId =
  | "load-safely" | "parse-strictly" | "index-payloads" | "core-schemas"
  | "signatures" | "authorization-thresholds" | "metadata-profiles" | "authorization"
  | "graph-dependency-artifacts" | "graph" | "roots-and-heads" | "completeness-anchor"
  | "freshness-anchors" | "artifact-bytes" | "artifact-profiles" | "decision";

export interface Check {
  id: CheckId;
  status: CheckStatus;
  prerequisiteChecks: CheckId[];
}

export interface Diagnostic {
  code: `E_${string}` | `W_${string}`;
  step: number;
  causedByCheck: CheckId;
  message: string;
  context: Record<string, unknown>;
}

export interface VerificationReport {
  reportVersion: "0.2";
  decision: Decision;
  primaryError: `E_${string}` | null;
  bundleId: string | null;
  evaluationTime: string;
  policyDigest: Digest;
  manifestDigest: Digest | null;
  checks: Check[];
  errors: Diagnostic[];
  warnings: Diagnostic[];
  // Counters are null when verification stopped before they could be computed.
  summary: {
    statementsTotal: number | null;
    statementsValid: number | null;
    statementsAuthorized: number | null;
    signaturesTotal: number | null;
    signaturesValid: number | null;
    artifactsDeclared: number | null;
    artifactsChecked: number | null;
  };
  tool: { name: string; version: string };
}

// JSON.parse returns unknown data. Narrow it before any branch trusts it.
export function isVerificationReport(value: unknown): value is VerificationReport {
  if (typeof value !== "object" || value === null) return false;
  const r = value as Record<string, unknown>;
  return (
    r.reportVersion === "0.2" &&
    (r.decision === "allow" || r.decision === "deny" || r.decision === "indeterminate") &&
    (r.primaryError === null || typeof r.primaryError === "string") &&
    Array.isArray(r.checks) &&
    Array.isArray(r.errors) &&
    typeof r.summary === "object" && r.summary !== null
  );
}

The guard is deliberately shallow: it establishes the fields the gate branches on. If you persist or forward reports, validate them against the hosted schema with AJV as well.

3. The complete program

Save this as makoto-gate.ts beside the types file.

// Validate, attest, verify, and gate on a Makoto report from TypeScript.
import { spawnSync, type SpawnSyncReturns } 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";
import { isVerificationReport, type VerificationReport } from "./makoto-report.ts";

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/";

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 {
  readonly report: VerificationReport;
  constructor(report: VerificationReport) {
    super(`${report.decision}: ${report.primaryError}`);
    this.report = report;
  }
}

function makoto(...args: string[]): SpawnSyncReturns<string> {
  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: string, schemaName: string): Promise<string[]> {
  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: unknown = JSON.parse(readFileSync(instancePath, "utf8"));
  if (validate(instance)) return [];
  return (validate.errors ?? []).map((e) => `${e.instancePath || "(root)"} ${e.message}`);
}

export function attestOrigin(artifactPath: string, workDir: string): string {
  const privateKey = join(workDir, "producer.private.pem");
  const out = join(workDir, "origin.dsse.json");
  const steps: string[][] = [
    ["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,
    ],
  ];
  for (const args of steps) {
    const result = makoto(...args);
    if (result.status !== 0) throw new MakotoToolError(result.stderr.trim());
  }
  return out;
}

export function verifyBundle(bundleDir: string): VerificationReport {
  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",
  );
  if (result.status !== 0 && result.status !== 1) {
    throw new MakotoToolError(result.stderr.trim() || `exit ${result.status ?? result.signal}`);
  }
  let parsed: unknown;
  try {
    parsed = JSON.parse(result.stdout);
  } catch (error) {
    throw new MakotoToolError(`malformed report: ${(error as Error).message}`);
  }
  if (!isVerificationReport(parsed)) throw new MakotoToolError("output is not a verification report");
  if ((result.status === 0) !== (parsed.decision === "allow")) {
    throw new MakotoToolError("exit status and report decision disagree");
  }
  if (parsed.decision !== "allow") throw new MakotoDenied(parsed);
  return parsed;
}

export function explain(report: VerificationReport): void {
  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");
console.log("signed", attestOrigin(artifact, work));

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 finalName = readdirSync(finalDir).find((name) => name.endsWith(".bin"));
if (finalName === undefined) throw new Error("fixture has no final artifact");
const finalFile = join(finalDir, finalName);
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;
}
npx tsc -p .
MAKOTO_CORE=/path/to/makoto node makoto-gate.ts

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 /var/folders/22/0hyg7lbs2fb34hr0ryq3sl1m0000gn/T/makoto-W3KsF0/origin.dsse.json
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

4. Verify, narrow, 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 function verifyBundle(bundleDir: string): VerificationReport {
  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",
  );
  if (result.status !== 0 && result.status !== 1) {
    throw new MakotoToolError(result.stderr.trim() || `exit ${result.status ?? result.signal}`);
  }
  let parsed: unknown;
  try {
    parsed = JSON.parse(result.stdout);
  } catch (error) {
    throw new MakotoToolError(`malformed report: ${(error as Error).message}`);
  }
  if (!isVerificationReport(parsed)) throw new MakotoToolError("output is not a verification report");
  if ((result.status === 0) !== (parsed.decision === "allow")) {
    throw new MakotoToolError("exit status and report decision disagree");
  }
  if (parsed.decision !== "allow") throw new MakotoDenied(parsed);
  return parsed;
}

Nothing reads parsed.decision until isVerificationReport has narrowed it, so a truncated or unexpected stdout becomes a MakotoToolError instead of a silently wrong branch. Because MakotoDenied carries a typed report, the catch site gets completion on primaryError and checks:

const tampered = join(work, "tampered-bundle");
cpSync(join(DEMO, "positive-bundle"), tampered, { recursive: true });
const finalDir = join(tampered, "artifacts/final");
const finalName = readdirSync(finalDir).find((name) => name.endsWith(".bin"));
if (finalName === undefined) throw new Error("fixture has no final artifact");
const finalFile = join(finalDir, finalName);
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;
}

5. Read the verification report

CheckId is a closed union, so a typo in a check name fails compilation rather than matching nothing at runtime.

export function explain(report: VerificationReport): void {
  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)}`);
}

Structure-only validation with AJV is identical to the JavaScript 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.

Troubleshooting

TS2351: This expression is not constructable on new Ajv2020()
Under module: NodeNext, the default import of AJV’s CommonJS build types as the module object. Use the named import import { Ajv2020 } from "ajv/dist/2020.js", which is correct for both the type checker and Node at runtime.
ERR_UNKNOWN_FILE_EXTENSION ".ts"
Your Node.js predates default type stripping. Upgrade to a current release, or compile first with tsc and a non-noEmit config and run the emitted JavaScript.
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