@pugi/plugin-mutation-ledger
v0.1.0-alpha.2
Published
Pugi mutation-ledger plugin - append-only structured event ledger with hash-chain integrity + SQLite index for tool / file / shell mutations.
Maintainers
Readme
@pugi/plugin-mutation-ledger
Append-only structured event ledger with hash-chain integrity, SQLite query index, and verification-gate veto for the Pugi soft fork of Pugi.
Part of the Pugi 1.0 soft fork sprint (see ADR-0081). Replaces the model's narrated "I created the file" / "tests passed" with a tamper-evident JSONL of actual write + execution events. The Phase 1 runtime verification gate (codeforge task #299 / #301) reads this ledger to refuse a session.finish that lacks real proof of success.
Install
pnpm add @pugi/plugin-mutation-ledger better-sqlite3 uuidUsage
// pugi.config.ts
export default {
plugin: [
['@pugi/plugin-mutation-ledger', {
// Defaults shown - everything is optional.
ledgerPath: '.pugi/mutation-ledger.jsonl',
mirrorDbPath: '.pugi/mutation-ledger.sqlite',
enableIntegrityHash: true,
enableSqliteMirror: true,
vetoOnFailedVerification: false,
}],
],
};Event schema
Every entry is one JSON line. Discriminated union on type:
| type | payload |
|-------------------|---------------------------------------------------------------------------|
| file_write | path, bytes, sha256, prevSha256?, mode, diffSummary? |
| file_edit | same as file_write with mode='edit' |
| file_delete | path, prevSha256 |
| shell_exec | command (SCRUBBED), exitCode, durationMs, stdoutBytes, stderrBytes, workingDir |
| tool_invocation | toolName, argsHash (sha256 of canonical JSON), resultStatus, durationMs |
| session_event | event (created / finish / veto / resumed / aborted), reason? |
| verification | check (build / test / lint / typecheck / health_probe), passed, evidence |
Every entry also carries ts (ISO-8601), sessionId, toolUseId?,
prevHash (sha256 of the previous entry; empty string for the first), and
eventId (UUID v7, time-ordered, monotonically sortable).
Hash chain integrity
The ledger is tamper-evident, not tamper-proof. verifyIntegrity() walks
the file from start, recomputes each entry's sha256 over a canonical JSON
form (recursively sorted keys), and compares against the next entry's
prevHash. Any mismatch surfaces the first break with line number and
reason. Defense in depth - off-machine replication - is tracked under
mode: 'replicated' (deferred).
Sample tamper detection:
import { verifyIntegrity } from '@pugi/plugin-mutation-ledger';
const result = await verifyIntegrity('.pugi/mutation-ledger.jsonl');
if (!result.ok) {
console.error(`ledger break at line ${result.brokenAt}: ${result.reason}`);
}Atomic append
The writer opens the JSONL file with O_APPEND for every entry. On POSIX,
write(2) on an O_APPEND fd is per-syscall atomic: the seek + append
happen in one operation so concurrent writers from sibling processes
cannot tear an entry. Within a single Node process we serialize through a
per-file async queue so prevHash is always read consistently before
each append. Entry size is capped at maxLineBytes (default 64 KiB) and
LineTooLargeError is thrown when an event would exceed it - fail loud
beats silent truncation.
SQLite mirror
The JSONL file is the source of truth; the SQLite mirror is a derived index for O(log n) queries. Schema:
CREATE TABLE events (
event_id TEXT PRIMARY KEY, -- UUID v7
ts TEXT NOT NULL,
session_id TEXT NOT NULL,
tool_use_id TEXT,
type TEXT NOT NULL,
prev_hash TEXT NOT NULL,
data TEXT NOT NULL -- full JSON
);
CREATE INDEX idx_events_ts ON events(ts);
CREATE INDEX idx_events_session ON events(session_id);
CREATE INDEX idx_events_type ON events(type);
CREATE INDEX idx_events_tool ON events(tool_use_id);WAL mode is enabled so concurrent readers (the Console mutation timeline,
the verification gate) do not block the writer. If the SQLite file
becomes corrupt, call rebuildMirror(db, ledgerPath) to drop and replay
every row from the JSONL log.
Registered tools
The plugin registers four tools that the model can call:
| Tool | Purpose |
|----------------------------|----------------------------------------------------------------------|
| ledger_history | Filtered event list (sessionId, type, sinceTs, limit) |
| ledger_verify_integrity | Walk the chain, return first break or clean status |
| ledger_summary | Per-session counts, total duration, files modified, verifications |
| ledger_assert_evidence | Return events supporting a claim (tests_pass / build_success / ...) |
Verification gate integration
When vetoOnFailedVerification: true, the plugin registers a
experimental.session.finish veto hook (Day 6 patch C of the Pugi
pugi fork). The hook reads ledger evidence and rejects the finish
when:
- Any
verificationentry withcheck='build'recordedpassed: false. - Any
verificationentry withcheck='test'recordedpassed: false. - Zero verification entries exist and
reasonlooks like success.
The veto decision lives as a pure exported function
(shouldVetoSessionFinish), so it is testable without depending on the
hook firing. The published @pugi-ai/[email protected] runtime ignores
the unknown hook key - gracefully degrading rather than blocking
sessions for users who are not on the Pugi fork.
import { shouldVetoSessionFinish } from '@pugi/plugin-mutation-ledger';
const decision = await shouldVetoSessionFinish(
{ ledgerPath: '.pugi/mutation-ledger.jsonl', db: null, vetoOnFailedVerification: true },
{ sessionId, reason: 'success' },
);
if (decision.veto) console.error(decision.vetoReason);Secret scrubbing
Plaintext secrets must never reach the ledger. scrubSecrets() runs over
every shell_exec.command and any text-bearing payload before write,
replacing matches with [REDACTED]. Default patterns:
| Pattern | Example match |
|-------------------------------|--------------------------------------------------|
| OAuth bearer | Bearer eyJhbGciOiJIUzI1... |
| OpenAI key | sk-..., sk-proj-... |
| Anthropic key | sk-ant-... |
| Stripe live secret | sk_live_..., rk_live_..., pk_live_... |
| GitHub PAT (classic/fine) | ghp_..., gho_..., ghu_..., ghs_..., ghr_... |
| Slack bot/user/app token | xoxb-..., xoxp-..., xoxa-..., xoxr-... |
| AWS access key ID | AKIA..., ASIA... |
| GCP API key | AIza... |
| Env-var assignment | AWS_SECRET=..., GITHUB_TOKEN=..., etc. |
| Hardcoded password | password = 'hunter2' |
| RSA/EC/OpenSSH/PGP key block | -----BEGIN ... PRIVATE KEY----- |
Append your own via secretScrubPatterns: [/CUSTOM-[A-Z0-9]+/g].
Pattern provenance: GitHub secret-scanning documented prefixes, AWS IAM identifier reference, Stripe key format conventions, OpenSSL PEM RFC 7468, historical breach disclosures (e.g. CVE-2022-26134-style env-var leaks).
Threat model
| Threat | Mitigation |
|--------------------------------------|----------------------------------------------------------------------------|
| Silent corruption of an entry | Hash chain breaks at the tampered index; verifyIntegrity reports line |
| Concurrent writer races | O_APPEND single syscall + per-file in-process queue |
| Plaintext secret persisted | scrubSecrets() runs BEFORE write; default + custom patterns |
| Mirror divergence from JSONL | rebuildMirror() drops + replays every row from JSONL |
| Disk full / partial write | Best-effort error swallowed (stderr); tool execution continues |
| Model claims "done" without proof | vetoOnFailedVerification rejects finish lacking verification events |
| Attacker rewrites whole chain | Out of scope today; mode='replicated' (off-machine relay) deferred |
Hook surface
tool.execute.before- capture start time + tool nametool.execute.after- emittool_invocation+ (for file-write tools)file_write/file_edit+ (for bash)shell_execexperimental.session.finish- opt-in veto whenvetoOnFailedVerification: true(fork-only; published runtime ignores)dispose- close SQLite handle
License
MIT. See LICENSE.
