Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
**/node_modules
**/dist
**/*.tsbuildinfo
.git
.claude
dev-plan
docs
examples/*/out
reversi-data
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: reversi
POSTGRES_PASSWORD: reversi
POSTGRES_DB: reversi_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U reversi" --health-interval 5s --health-timeout 5s --health-retries 10
env:
REVERSI_TEST_PG: postgres://reversi:reversi@localhost:5432/reversi_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: npm ci
- run: npm test
- run: npm run test:py
- name: Fixtures are in sync
run: node scripts/gen-fixtures.mjs --check
supply-chain:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Dependency audit
run: npm audit --omit=dev --audit-level=high
- name: SBOM
run: npm sbom --sbom-format cyclonedx --omit dev > sbom.cdx.json
- uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.cdx.json
- name: Secret scan (gitleaks CLI; the action needs a paid licence for organisations)
run: |
VERSION=8.21.2
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" | tar -xz gitleaks
./gitleaks detect --source . --config .gitleaks.toml --redact --verbose
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t reversi:ci .
40 changes: 40 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: release
on:
push:
tags: ["v*"]
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write # npm provenance and build attestations
attestations: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm test
- name: SBOM
run: npm sbom --sbom-format cyclonedx --omit dev > sbom.cdx.json
- name: Pack
run: |
mkdir dist-packages
for p in reversi cli server gateway connectors; do npm pack -w packages/$p --pack-destination dist-packages; done
- uses: actions/attest-build-provenance@v2
with:
subject-path: "dist-packages/*.tgz"
- name: Publish with provenance
run: for t in dist-packages/*.tgz; do npm publish "$t" --provenance --access public; done
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: |
sbom.cdx.json
dist-packages/*.tgz
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ CLAUDE.md
CLAUDE.local.md
.claude/
*.tsbuildinfo
deploy/keys.json
try/
4 changes: 4 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Test fixtures contain deliberately fake credential-shaped strings (they exercise redaction and content checks).
[allowlist]
description = "fake credentials in tests and docs"
paths = ['''packages/.*/test/.*''', '''docs/.*''']
13 changes: 11 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@

```sh
npm install
npm test # builds, runs the TypeScript tests (core, CLI, server)
npm test # builds, runs the TypeScript tests (core, CLI, server, gateway, connectors)
npm run test:py # Python SDK tests, including a live run against the built server
npm run example # end-to-end demo
```

Both test commands must pass. CI runs them on Node 22 and Python 3.9 and 3.12.
Both test commands must pass. Node 22.13+ is required (`node:sqlite`). The PostgreSQL tests run when `REVERSI_TEST_PG`
points at a **throwaway** database (each run creates its own schema in it):

```sh
REVERSI_TEST_PG=postgres://user:pass@localhost:5432/reversi_test npm test
```

CI runs everything on Node 22 with a PostgreSQL service, and the Python tests on 3.12.

Docs live in `docs/` (Mintlify: `docs/docs.json` is the navigation). Update the page for any behaviour you change.

## Ground rules

Expand Down
22 changes: 22 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# One image, three programs: reversi-server (default), reversi-gateway, reversi (CLI).
# docker run ... reversi # server
# docker run ... reversi node packages/gateway/dist/main.js --config /config/gateway.yaml --manifests /config/manifests
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.base.json tsconfig.json ./
COPY packages ./packages
RUN npm ci && npx tsc -b && npm prune --omit=dev

FROM node:22-slim
ENV NODE_ENV=production REVERSI_DATA=/data HOST=0.0.0.0 PORT=7421
WORKDIR /app
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages ./packages
RUN mkdir /data && chown node:node /data
USER node
VOLUME /data
EXPOSE 7421
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||7421)+'/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
CMD ["node", "packages/server/dist/main.js"]
53 changes: 36 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
# Reversi

**Reversibility, approvals and tamper-evident evidence for every tool an AI agent can touch.**
**Reversibility, approvals, policy and tamper-evident evidence for every tool an AI agent can touch.**

Agents write to real systems: orders, payments, tickets, email. When a run goes wrong you need three
answers: *what did it change, why, and how do we undo it?* Reversi sits between an agent and its tools
and answers all three.

- **Tool manifests** declare how reversible each tool is, what undoes it, and which permission it needs.
- **Write-ahead journal**: intent is recorded *before* a tool runs; if journaling fails, the call doesn't run.
- **Saga rollback**: newest first, idempotent, dependency-aware, and it never claims an irreversible
step was undone.
- **Hash-chained ledger**: edits, deletions and reordering are detectable (Python stdlib, CLI or the browser viewer).
Arguments and results are hashed, never stored.
- **Approvals and scopes**: irreversible calls need an operator. Agents can't approve themselves or roll back.
- **Observe mode**: journal what *would* be blocked before enforcing anything.
- **Saga rollback**: newest first, idempotent, dependency-aware, with retries and verification. It never claims an
irreversible step was undone, and unknown outcomes are reconciled by a human, not guessed.
- **Hash-chained ledger** on files, SQLite or PostgreSQL: edits, deletions and reordering are detectable. Arguments and
results are hashed, never stored; credentials in free text are redacted.
- **Policy as code**: allow/deny/approve rules, rate limits, environments, tests (`reversi policy test`), plus content
checks for credentials, hidden payloads, injection phrasing and exfiltration sequences.
- **Approvals**: queued, bound to the exact arguments, single use, expiring, persisted, with webhook notification.
- **MCP gateway**: put it in front of any MCP server (stdio or Streamable HTTP) to journal, enforce, approve and roll
back without changing the agent. Operator tools live on a separate authenticated endpoint.
- **Console, API and metrics**: roles (agent, auditor, approver, operator, admin), key rotation, OIDC, Prometheus,
kill switch, signed evidence bundles and auditor reports.
- **Connectors** for filesystem, GitHub, Slack and Stripe with working compensations.

## Try it

Expand All @@ -23,26 +29,39 @@ npm run example # agent places an order, hits a blocked call, operator roll
open viewer/index.html # load examples/order-agent/out/ledger.jsonl; the chain is verified in your browser
```

Node 22.13+ is required. For the gateway, see [examples/gateway](examples/gateway) and the
[gateway guide](docs/gateway.mdx).

## What's here

| Path | What |
|---|---|
| `packages/reversi` | TypeScript core: manifests, ledger, scopes, engine (`@multimindlab/reversi`) |
| `packages/cli` | `reversi validate / verify / runs / plan / rollback / export` |
| `packages/gateway` | MCP gateway, observe mode (`reversi-gateway`) |
| `packages/server` | HTTP API (Hono) with agent and operator API keys |
| `packages/reversi` | Core: manifests, ledger, engine, policy, content checks, approvals, evidence, stores, auth (`@multimindlab/reversi`) |
| `packages/cli` | `reversi` command line |
| `packages/server` | HTTP API, operator console and metrics (Hono) |
| `packages/gateway` | MCP gateway (`reversi-gateway`) |
| `packages/connectors` | Filesystem, GitHub, Slack, Stripe connectors and `reversi-connectors` |
| `packages/sdk-py` | Python client and offline verifier, stdlib only (`reversi-sdk`) |
| `viewer/` | Single-file trace viewer |
| `examples/` | TypeScript and Python agents |
| `docs/` | Mintlify docs (`reversi-docs.multimind.dev`) |
| `examples/` | TypeScript, Python and gateway examples |
| `docs/` | Mintlify docs (`docs/docs.json` is the navigation) |
| `deploy/`, `Dockerfile` | Docker image and a Compose stack on PostgreSQL |
| `schemas/`, `conformance/` | JSON Schemas and cross-language test fixtures |

## Honest status

Built and tested: everything in the table above. **Not built:** MCP gateway enforcement (it only observes), Postgres store,
a hosted dashboard, content checks for prompt injection. Reversi enforces what manifests declare;
it cannot discover what a tool really does, and the ledger is tamper-*evident*, not tamper-proof.
Read [docs/limits.mdx](docs/limits.mdx) before relying on it.
Built and tested with unit and integration tests, including a live PostgreSQL run and real stdio processes.
**Not exercised here:** the connectors against live GitHub, Slack and Stripe (they are tested against fakes of those
APIs), the Dockerfile, Compose stack and GitHub workflows (written, not executed). **Not built:** OpenTelemetry traces,
risk scoring, retention tooling, framework adapters, a hosted service. Reversi enforces what manifests declare; it
cannot discover what a tool really does, content checks are heuristics, and the ledger is tamper-*evident*, not
tamper-proof. Read [limits](docs/limits.mdx) and the [threat model](docs/threat-model.mdx) before relying on it.

## Docs

The docs are Mintlify pages in [docs/](docs). To publish them, connect this repository in the Mintlify dashboard, install
its GitHub app, and set the docs directory to `/docs` (a monorepo setup); Mintlify then rebuilds on every push to the
configured branch.

## License

Expand Down
7 changes: 4 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Roadmap

See [docs/roadmap.mdx](docs/roadmap.mdx). Built: manifests, write-ahead ledger, dependency-aware
rollback, scopes, approvals, observe mode, CLI, HTTP API, Python SDK, trace viewer. Next: validate
with MCP tool authors, then an MCP gateway (observe mode first).
See [docs/roadmap.mdx](docs/roadmap.mdx) for what is built and what is not. In short: the engine, policy, approvals,
content checks, signed evidence, three stores, the server with console, the MCP gateway, four connectors, the CLI and the
Python SDK are built. Not built: OpenTelemetry traces, risk scoring and delegated approval, retention tooling, more
connectors, framework adapters, a hosted service.
14 changes: 8 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ reports within a few working days.

## Scope notes

- The ledger detects tampering; it does not prevent a writer who controls the whole file from
forging a consistent one, or from cutting off the end. Anchor the head hash externally. See
[docs/concepts/ledger.mdx](docs/concepts/ledger.mdx); this is a known limitation, not a vulnerability.
- Content checks (intent graphs, encoding detection) are not implemented. Do not rely on Reversi
to block prompt injection today.
- Approval grants are held in server memory and are revoked on restart by design.
- The ledger detects tampering; it does not prevent a writer who controls the whole file or database
from forging a consistent one, or from cutting off the end. Anchor the head hash externally. See
[docs/concepts/evidence.mdx](docs/concepts/evidence.mdx); this is a known limitation, not a vulnerability.
- Content checks are heuristics (credentials, encoded payloads, injection phrasing, cumulative intent). Bypassing them with
creative phrasing is a known limitation; a bypass of a *documented* pattern is a bug worth reporting.
- Approvals, API-key handling, OIDC validation, the console session and CSRF protection, Host/Origin validation on the
gateway and the role separation between agent and operator endpoints are in scope. See
[docs/threat-model.mdx](docs/threat-model.mdx).
41 changes: 41 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Reversi server on PostgreSQL. From the repo root:
# node packages/connectors/dist/main.js manifests fs --format yaml > examples/gateway/manifests/fs.yaml (after npm run build)
# cp deploy/keys.example.json deploy/keys.json # then replace the placeholder hashes (see the comments in the file)
# POSTGRES_PASSWORD=$(openssl rand -hex 16) docker compose -f deploy/docker-compose.yml up --build
# The console is at http://127.0.0.1:7421/console . Put TLS in front of it for anything beyond localhost.
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: reversi
POSTGRES_DB: reversi
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reversi"]
interval: 5s
retries: 10

reversi:
build:
context: ..
depends_on:
postgres:
condition: service_healthy
environment:
REVERSI_MANIFESTS: /config/manifests
REVERSI_KEYS: /config/keys.json
REVERSI_POLICY: /config/policy.yaml
REVERSI_STORE: postgres
REVERSI_DATABASE_URL: postgres://reversi:${POSTGRES_PASSWORD}@postgres:5432/reversi
REVERSI_LOG: json
volumes:
- ./keys.json:/config/keys.json:ro
- ../examples/gateway/manifests:/config/manifests:ro
- ../examples/gateway/policy.yaml:/config/policy.yaml:ro
ports:
- "127.0.0.1:7421:7421"

volumes:
pgdata:
6 changes: 6 additions & 0 deletions deploy/keys.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{ "name": "order-agent", "role": "agent", "key_sha256": "REPLACE: output of printf '%s' \"$AGENT_KEY\" | shasum -a 256", "max_permissions": ["fs:read", "fs:write"] },
{ "name": "alice", "role": "approver", "key_sha256": "REPLACE" },
{ "name": "ops", "role": "operator", "key_sha256": "REPLACE", "expires_at": "2027-01-01T00:00:00Z" },
{ "name": "root", "role": "admin", "key_sha256": "REPLACE" }
]
35 changes: 35 additions & 0 deletions docs/api-reference/admin.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
title: Approvals, evidence and admin endpoints
description: Decide approvals, download signed bundles, check status, flip the kill switch.
---

## Approvals

### `GET /v1/approvals?status=&run_id=` (auditor+)
`{"approvals":[{"id","run_id","step","kind","tool","reason","triggers":[…],"arg_keys":[…],"args_hash","status","requested_ms","expires_ms","decided_by","decided_ms","decision_note"}]}`.
`status`: `pending`, `approved`, `denied`, `consumed`, `expired`. Only argument **names** are stored.

### `POST /v1/approvals/{id}/approve` and `/deny` (approver+)
Optional body `{"note":"…"}`. Returns the updated record. 409 if it is no longer pending, 404 if unknown.

## Evidence

### `GET /v1/runs/{id}/bundle` and `GET /v1/ledger/bundle` (auditor+)
A signed [evidence bundle](/concepts/evidence). 404 unless the server has `REVERSI_SIGNING_KEY`.

### `GET /v1/evidence/key` (auditor+)
`{"alg":"ed25519","key_id":"…","public_key_pem":"…"}`.

## Status and kill switch

### `GET /v1/status` (auditor+)
`{"halted": null | "reason","environment","runs","pending_approvals"}`.

### `POST /v1/halt` (admin)
`{"reason":"incident 42"}`. Every new call is blocked until resumed; rollbacks still work. The caller's name is appended to the reason.

### `POST /v1/resume` (admin)

## Operational

`GET /healthz`, `GET /readyz`, `GET /metrics`: see [monitoring](/operations/monitoring).
Loading
Loading