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.
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 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 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/nodeThis 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.tsOne 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 614eb137303b4. 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
errorClassandmessage. 3· internal error- Same stderr shape with
errorClassset tointernal. Treat it as an outage, never as a verdict.
Troubleshooting
TS2351: This expression is not constructableonnew Ajv2020()- Under
module: NodeNext, the default import of AJV’s CommonJS build types as the module object. Use the named importimport { 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
tscand a non-noEmitconfig and run the emitted JavaScript. - 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