Can I run Rein without trusting your servers?

Short answer

Yes, and you should be able to. Rein is MIT-licensed and needs no account. Install it from npm and check the provenance, run the same persistent engine the hosted service runs on your own disk, and verify its signed decision log with a script that imports nothing from Rein. About ten minutes, Node 22+, nothing leaves your machine.

1. Install, and check what you installed

In an empty folder:

terminal
npm init -y
npm install @reinconsole/sdk @reinconsole/store @reinconsole/mock-rails @reinconsole/core
npm audit signatures
what you'll see — from our run
audited 73 packages in 7s

73 packages have verified registry signatures
26 packages have verified attestations

The attestations are npm provenance: each @reinconsole package was built and published by the release workflow in the public repo, from a tagged commit, with no npm token in the loop. Each package's npm page links the exact commit.

2. Run the engine on your own disk

In a second terminal, in the same folder. This is rein-engine from @reinconsole/store, the persistent engine the hosted service runs. It keeps agents, policies, its signing key and the decision log in ./rein-data.

terminal 2 — leave this running
REIN_DATA_DIR=./rein-data npx -p @reinconsole/store rein-engine
# PowerShell: $env:REIN_DATA_DIR = './rein-data'; npx -p @reinconsole/store rein-engine
what you'll see — from our run
[rein] no REIN_ENGINE_API_KEY set — binding 127.0.0.1 only. Set one before exposing this engine.
[rein] persistent policy-engine listening on http://127.0.0.1:8787 (auth: none)
[rein] data dir ./rein-data — fresh store; signing key stored

With no API key set it binds to localhost only, so nothing else on the network can reach it. The ed25519 signing key is generated here and stays in that folder.

3. Govern five payments

One agent, a $1.00 per-payment cap and the kill switch, on simulated x402 rails, so no money and no chain are involved. Save as govern.mjs in the same folder:

govern.mjs
// govern.mjs — five payment attempts against YOUR engine, on simulated rails.
// Prereq: the engine from step 03 is running on 127.0.0.1:8787.
import { newId } from '@reinconsole/core';
import { createGuard, EngineClient, PaymentBlockedError } from '@reinconsole/sdk';
import { MockLedger, MockFacilitator, createMockVendor } from '@reinconsole/mock-rails';

const engineUrl = 'http://127.0.0.1:8787';
const wallet = '0xLocalAgent01';
const ledger = new MockLedger();
const facilitator = new MockFacilitator({ ledger, name: 'mock-facilitator' });
const cheap = createMockVendor({ facilitator, atomicPrice: '10000', payTo: '0xVendor' }); // $0.01
const pricey = createMockVendor({ facilitator, atomicPrice: '5000000', payTo: '0xVendor' }); // $5.00

const client = new EngineClient({ baseUrl: engineUrl });
const agent = await client.registerAgent({
  orgId: newId('org'),
  name: 'local-agent',
  wallets: [{ chain: 'base', address: wallet, mode: 'sdk' }],
});
await client.addPolicy({
  policyId: `local-${agent.id}`,
  appliesTo: { agents: [agent.id] },
  rules: [{ id: 'tx-cap', deny: { amountGt: '1.00' } }],
  default: 'allow',
});

const pay = (vendor) =>
  createGuard({ engineUrl, agentId: agent.id, fetch: vendor.fetch, payer: facilitator.payerFor(wallet) }).wrap();

async function attempt(label, vendor) {
  try {
    await pay(vendor)('https://api.data.test/v1/query');
    console.log(`${label}  ALLOW`);
  } catch (e) {
    if (!(e instanceof PaymentBlockedError)) throw e;
    console.log(`${label}  DENY   ${e.decision.reason}`);
  }
}

await attempt('$0.01', cheap);
await attempt('$0.01', cheap);
await attempt('$5.00', pricey);           // over the $1.00 per-tx cap
await client.freeze(agent.id);            // the kill switch
await attempt('$0.01', cheap);
await client.unfreeze(agent.id);
await attempt('$0.01', cheap);
console.log(`ledger: ${ledger.entries().length} payments settled, 2 never constructed`);
terminal
node govern.mjs
what you'll see — from our run
$0.01  ALLOW
$0.01  ALLOW
$5.00  DENY   denied by: tx-cap
$0.01  DENY   agent is frozen (kill switch)
$0.01  ALLOW
ledger: 3 payments settled, 2 never constructed

4. Verify the log without our code

Every decision is sha256-hashed over its content plus the previous hash, then ed25519-signed. This script fetches the engine's public key and the whole log and checks both, with node:crypto and nothing else. Save as verify.mjs:

verify.mjs
// verify.mjs — check the engine's decision log with node:crypto alone.
// No Rein package is imported: this file trusts nothing we wrote.
import { createHash, createPublicKey, verify } from 'node:crypto';

const engine = process.env.REIN_ENGINE_URL ?? 'http://127.0.0.1:8787';
const { publicKey } = await (await fetch(`${engine}/health`)).json();
const key = createPublicKey(publicKey);

const log = [];
for (let after; ; ) {
  const res = await fetch(`${engine}/v1/decisions?limit=500${after === undefined ? '' : `&after=${after}`}`);
  log.push(...(await res.json()));
  after = res.headers.get('rein-next-after') ?? undefined;
  if (after === undefined) break;
}

// --tamper rewrites the $5.00 deny as an allow, the edit an attacker would want.
if (process.argv.includes('--tamper')) log.find((d) => d.outcome === 'deny').outcome = 'allow';

let prev = 'genesis';
for (const [i, d] of log.entries()) {
  // The exact bytes the engine hashed: these eight fields, in this order.
  const canonical = JSON.stringify({
    intentId: d.intentId, intentHash: d.intentHash, outcome: d.outcome,
    matchedRules: d.matchedRules, policyId: d.policyId, policyVersion: d.policyVersion,
    prevHash: d.prevHash, decidedAt: new Date(d.decidedAt).toISOString(),
  });
  const hash = createHash('sha256').update(canonical).digest('hex');
  const signed = verify(null, Buffer.from(d.hash), key, Buffer.from(d.signature, 'base64'));
  if (d.prevHash !== prev || hash !== d.hash || !signed) {
    console.log(`BROKEN at #${i} (${d.id}): ${d.prevHash !== prev ? 'chain' : hash !== d.hash ? 'hash' : 'signature'}`);
    process.exit(1);
  }
  prev = d.hash;
}
console.log(`${log.length} decisions · chain intact · every signature valid · head ${prev.slice(0, 12)}…`);
terminal
node verify.mjs
node verify.mjs --tamper
what you'll see — from our run
5 decisions · chain intact · every signature valid · head 3376c09a8532…
BROKEN at #2 (dec_01M3QBKVRHSWBNMZM36S66W4AP): hash

The second run rewrites the $5.00 refusal as an allow before checking, and the check fails at that entry. An edit that also recomputed the hash would fail the signature instead, and a dropped entry breaks the chain.

5. Restart it: the record stays

Stop the engine (Ctrl+C), start it again with the same command, and run both scripts once more. The engine resumes from disk and the new decisions extend the same chain:

what you'll see — from our run
[rein] data dir ./rein-data — resumed 5 decisions, 1 agents, 1 policies, 0 api keys; signing key stored
...
10 decisions · chain intact · every signature valid · head 29403df62d6a…

What leaves your machine

From here to real payments

Swap the mock vendor's fetch for globalThis.fetch and the mock payer for your own, and the same engine governs real x402 payments. The runbook has a Base Sepolia track with free testnet USDC. Before exposing the engine beyond localhost, set REIN_ENGINE_API_KEY; the deploy guide covers the rest.

Related questions