diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7546ace..e274a20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,8 @@ jobs: node: 24 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ matrix.node }} cache: npm @@ -40,8 +40,8 @@ jobs: name: Package validation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 cache: npm diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d55ab..ca82d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to AgentTX are documented here. The project follows Semantic Versioning. +## Unreleased + +### Added + +- Successful rollback now emits path-free, hash-linked evidence recording whether the Git-visible original workspace status changed during rollback. +- `agenttx evidence ` regenerates rollback evidence from the terminal ledger when the initial atomic artifact write is unavailable. +- `agenttx verify-evidence ` checks the canonical outer receipt digest and every offline-derivable invariant without claiming authentication. +- Workspace evidence now binds tracked diffs and untracked content fingerprints, and rollback metadata is bound into the terminal ledger event. + +### Changed + +- Bump the unreleased package identity to `0.2.0`. +- Pin CI checkout and Node setup actions to reviewed immutable commits. + ## [0.1.0] - 2026-08-08 ### Added diff --git a/README.md b/README.md index d2a59a1..be109bd 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ agenttx rollback ``` `agenttx commit` applies files to your working tree; it does **not** create or stage a Git commit. +`agenttx rollback` also writes a redacted `rollback-evidence.json` with discarded-change counts, a bound terminal event, and content-sensitive before/after digests recording whether the Git-visible original workspace stayed unchanged. Verify its unsigned integrity offline with `agenttx verify-evidence `. Try the real, deterministic demo with no model, credentials, remote, or network write: @@ -54,7 +55,7 @@ agenttx demo AgentTX runs the child command inside an independent local Git clone, from the equivalent repository directory. Your original working tree stays available and unchanged until you explicitly accept the transaction. After the child exits, inspect its diff, verification results, detected side effects, and risk; then commit or roll back. -> **Security boundary:** AgentTX v0.1.0 isolates supported repository changes, not the operating system. Child processes retain your normal user permissions, and external-action detection is heuristic. Read the [security model](docs/SECURITY_MODEL.md). +> **Security boundary:** AgentTX v0.2.0 isolates supported repository changes, not the operating system. Child processes retain your normal user permissions, and external-action detection is heuristic. Read the [security model](docs/SECURITY_MODEL.md). ## Why AgentTX? @@ -76,9 +77,11 @@ AgentTX captures the repository baseline, builds an independent local clone, ove | `agenttx inspect [id] [--json]` | Show changes, side effects, risk, and checks | | `agenttx verify [id] [--run]` | Discover checks; run them only with `--run` | | `agenttx commit [id]` | Accept transaction files after conflict checks | -| `agenttx rollback [id]` | Discard the isolated transaction | +| `agenttx rollback [id]` | Discard the isolated transaction and write rollback evidence | | `agenttx history [--json]` | List local transaction history | | `agenttx replay [--json]` | Read recorded events; it does not re-execute | +| `agenttx evidence [--output path]` | Regenerate redacted rollback evidence from the terminal ledger | +| `agenttx verify-evidence ` | Offline-check receipt integrity and derivable invariants; it does not authenticate the artifact | | `agenttx report [id] --html` | Write a standalone redacted HTML report | | `agenttx doctor [--json]` | Check Node, Git, repository state, storage, and agent CLIs | | `agenttx demo [--keep]` | Run the offline seven-file demo | @@ -101,6 +104,8 @@ Named adapters identify common CLIs; they do not depend on private agent hooks. Using AgentTX with Claude Code, Codex, Gemini CLI, OpenCode, or another coding agent? [Open a compatibility report](https://github.com/aliengineering-byte/agenttx/issues/new?template=agent-compatibility.yml). Real reports determine which agent-specific workflows receive deeper testing. +Found another problem or workflow gap? Open a [sanitized bug report](https://github.com/aliengineering-byte/agenttx/issues/new?template=bug.yml) or a [focused feature request](https://github.com/aliengineering-byte/agenttx/issues/new?template=feature.yml). + ## Safety model AgentTX gives a strong, narrow repository guarantee: before acceptance, rollback removes only the isolated transaction workspace; acceptance refuses overlapping changes in the original repository and restores from a recovery backup if file application fails. @@ -109,6 +114,8 @@ AgentTX is **not an OS security boundary**. The child retains your normal user p No telemetry, account, API key, Docker daemon, or cloud service is required. AgentTX does not upload code, paths, prompts, commands, diffs, or transaction metadata. +Rollback receipts are unsigned and recomputable. Their outer hash detects accidental or partial modification, while the offline verifier also checks the bound terminal event, metadata/diff references, and derived workspace result. It is integrity checking, not authentication against someone able to rewrite the complete local receipt and ledger. + Read [SECURITY.md](SECURITY.md), [the exact security model](docs/SECURITY_MODEL.md), and [the threat model](docs/THREAT_MODEL.md) before relying on AgentTX around untrusted code. ## Honest V0 limitations diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index ff541e9..72c46f8 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -1,6 +1,6 @@ # Dependency and license record -AgentTX v0.1.0 has **zero runtime dependencies**. +AgentTX v0.2.0 has **zero runtime dependencies**. The release environment resolved these direct development dependencies from `package-lock.json`: diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index c3d9709..f4cfac8 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -39,7 +39,7 @@ Run from the repository root. Record evidence in the release notes or release ha - [ ] GitHub CI green on the final release commit - [ ] release commit SHA recorded -- [ ] annotated `v0.1.0` tag prepared from that exact commit +- [ ] annotated `v0.2.0` tag prepared from that exact commit - [ ] package tarball SHA-256 recorded - [ ] GitHub repository publication verified - [ ] npm publication verified from the public registry diff --git a/docs/SCHEMAS.md b/docs/SCHEMAS.md index a54b2ee..9184288 100644 --- a/docs/SCHEMAS.md +++ b/docs/SCHEMAS.md @@ -117,3 +117,74 @@ File `kind` is `added`, `modified`, `deleted`, or `renamed`. Binary line counts Risk `level` is `LOW`, `MEDIUM`, `HIGH`, or `CRITICAL`. Every score contribution appears in `reasons`. Secret finding values are always the literal `[REDACTED]`. + +## Rollback evidence + +A successful `agenttx rollback` writes `rollback-evidence.json` in the transaction +directory and prints its location. If persistence fails, rollback remains complete +and the CLI prints `agenttx evidence ` as the regeneration path. +Evidence export accepts an existing byte-identical artifact but refuses to replace +different content, including at an explicit `--output` path. + +The schema has an unsigned receipt plus an outer integrity record: + +```json +{ + "receipt": { + "schemaVersion": 1, + "evidenceType": "agenttx.rollback", + "producer": { "version": "0.2.0", "...": "..." }, + "transaction": { "state": "ROLLED_BACK", "...": "..." }, + "result": { "originalWorkspaceStatusUnchanged": true, "...": "..." }, + "workspaceStatusEvidence": { + "algorithm": "sha256(agenttx-git-visible-content-v1)", + "before": "", + "after": "" + }, + "eventChain": { + "algorithm": "sha256(JSON.stringify(event))", + "events": 7, + "finalHash": "", + "terminalEvent": { "type": "rollback.completed", "...": "..." } + }, + "artifacts": { + "transactionDiff": { "sha256": "", "...": "..." }, + "transactionMetadata": { "sha256": "", "...": "..." } + }, + "redaction": { + "filePathsIncluded": false, + "commandArgumentsIncluded": false, + "privatePathsIncluded": false, + "secrets": "redacted" + }, + "limitations": ["..."] + }, + "integrity": { + "algorithm": "sha256", + "canonicalization": "agenttx-canonical-json-v1", + "scope": "receipt", + "authentication": "none", + "digest": "" + } +} +``` + +`agenttx-canonical-json-v1` serializes primitives with JSON rules, preserves array +order, and sorts object keys by explicit UTF-16 code-unit comparison. The outer +digest covers every field in `receipt`. `agenttx verify-evidence ` recomputes +that digest and checks strict field sets, formats and counts, the transaction +metadata digest, the terminal ledger event hash and cross-references, and the +workspace result derived from the before/after digests. + +`originalWorkspaceStatusUnchanged` is never trusted as an independent assertion. +It is `null` if either workspace digest is unavailable; otherwise it is exactly +the equality result for those digests. `agenttx-git-visible-content-v1` commits to +Git `HEAD`, porcelain-v2 status, the full binary tracked diff relative to `HEAD`, +and sorted untracked path/content fingerprints. Only the digests are exported; +ignored files and external systems remain outside the receipt. + +The terminal event binds the transaction ID, baseline/base object IDs, completion +time, discarded diff, and workspace digests without exporting commands, private +paths, or file names. The receipt is deliberately unsigned and recomputable. Its +hash detects accidental or partial alteration, but is not authentication and is +not proof against a party able to rewrite the complete local receipt and ledger. diff --git a/package-lock.json b/package-lock.json index 81e3d8d..bcb428b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agenttx", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agenttx", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "bin": { "agenttx": "dist/src/cli.js" diff --git a/package.json b/package.json index 5f9b56d..ffff9cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenttx", - "version": "0.1.0", + "version": "0.2.0", "description": "Git-style transactions for AI coding agents. Inspect, commit, or roll back agent changes.", "type": "module", "bin": { diff --git a/scripts/release-verify.mjs b/scripts/release-verify.mjs index 0850b92..de32361 100644 --- a/scripts/release-verify.mjs +++ b/scripts/release-verify.mjs @@ -66,6 +66,11 @@ async function initializeRepository(root) { } const project = resolve(fileURLToPath(new URL("..", import.meta.url))); +const manifest = JSON.parse(await readFile(join(project, "package.json"), "utf8")); +if (manifest.name !== "agenttx" || typeof manifest.version !== "string") { + throw new Error("Unexpected package identity in package.json"); +} +const expectedVersion = manifest.version; const temporary = await mkdtemp(join(tmpdir(), "agenttx-release-verify-")); const packDirectory = join(temporary, "pack"); const prefix = join(temporary, "prefix"); @@ -82,7 +87,7 @@ try { } else { await execute(packageManager, ["pack", "--pack-destination", packDirectory], { cwd: project }); } - const tarball = join(packDirectory, "agenttx-0.1.0.tgz"); + const tarball = join(packDirectory, `${manifest.name}-${expectedVersion}.tgz`); await access(tarball); if (hasNpm) { await execute(packageManager, ["install", tarball, "--prefix", prefix]); @@ -93,7 +98,7 @@ try { const shebang = (await readFile(cli, "utf8")).split("\n")[0]; if (shebang !== "#!/usr/bin/env node") throw new Error(`Unexpected CLI shebang: ${shebang}`); const version = (await execute(process.execPath, [cli, "--version"], { capture: true })).stdout.trim(); - if (version !== "0.1.0") throw new Error(`Unexpected installed version: ${version}`); + if (version !== expectedVersion) throw new Error(`Unexpected installed version: ${version}`); await initializeRepository(repository); const env = { ...process.env, AGENTTX_HOME: transactionHome }; diff --git a/src/cli.ts b/src/cli.ts index 209f0c7..c33702f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { resolveAdapter } from "./adapters/agent.js"; import { runDoctor, renderDoctor } from "./cli/doctor.js"; +import { verifyRollbackEvidenceFile, writeRollbackEvidence } from "./core/evidence.js"; import { inspectTransaction } from "./core/inspection.js"; import { EventLedger } from "./core/ledger.js"; import { redactText } from "./core/redaction.js"; @@ -30,6 +31,7 @@ import { renderTransactionReport, renderVerification } from "./reporters/terminal.js"; +import { VERSION } from "./version.js"; const execFileAsync = promisify(execFile); const cliPath = fileURLToPath(import.meta.url); @@ -62,7 +64,7 @@ function flagValue(args: readonly string[], flag: string): string | undefined { } function help(): string { - return `AgentTX 0.1.0 — Make AI agents undoable. + return `AgentTX ${VERSION} — Make AI agents undoable. Usage: agenttx run [--allow-external] [--] @@ -74,6 +76,8 @@ Usage: agenttx rollback [transaction-id] agenttx history [--json] agenttx replay [--json] + agenttx evidence [--output path] + agenttx verify-evidence agenttx report [transaction-id] --html [--output path] agenttx doctor [--json] agenttx demo [--keep] @@ -167,7 +171,21 @@ async function handleRollback(args: string[]): Promise { const result = await rollbackTransaction(metadata); print(`Transaction ${result.metadata.transactionId} rolled back.\n`); print(`Discarded ${result.diff.filesChanged} file change${result.diff.filesChanged === 1 ? "" : "s"}.`); - print("Original workspace unchanged."); + if (result.originalWorkspaceStatusUnchanged === true) { + print("Git-visible original workspace status unchanged (digest matched)."); + } else if (result.originalWorkspaceStatusUnchanged === false) { + print("Warning: Git-visible original workspace status changed during rollback; inspect the evidence artifact."); + } else { + print("Warning: original workspace status could not be verified; inspect the evidence artifact."); + } + if (result.evidencePath) { + print(`Rollback evidence: ${result.evidencePath}`); + } else { + process.stderr.write( + `Rollback completed, but evidence could not be written: ${result.evidenceWarning ?? "unknown error"}\n` + + `Regenerate it with: agenttx evidence ${result.metadata.transactionId}\n` + ); + } } async function handleHistory(args: string[]): Promise { @@ -188,6 +206,23 @@ async function handleReplay(args: string[]): Promise { for (const event of events) print(` ${String(event.seq).padStart(3)} ${event.timestamp} ${event.type}`); } +async function handleEvidence(args: string[]): Promise { + const id = positional(args)[0]; + if (!id) throw new Error("agenttx evidence requires a transaction ID."); + const metadata = await resolveTransaction(id, process.cwd()); + const path = await writeRollbackEvidence(metadata, flagValue(args, "--output")); + print(`Rollback evidence written to ${path}`); +} + +async function handleVerifyEvidence(args: string[]): Promise { + const path = positional(args)[0]; + if (!path) throw new Error("agenttx verify-evidence requires an evidence file."); + const verification = await verifyRollbackEvidenceFile(path); + print(`Evidence integrity verified for ${verification.transactionId}.`); + print(`Receipt SHA-256: ${verification.digest}`); + print("Authentication: none — this is unsigned, recomputable integrity, not authentication."); +} + async function handleReport(args: string[]): Promise { if (!hasFlag(args, "--html")) throw new Error("V0 report output requires --html."); const id = positional(args)[0]; @@ -250,6 +285,10 @@ async function main(): Promise { process.exitCode = await runShim(transactionId, tool, executable, args.slice(separator + 1)); return; } + if (command === "verify-evidence") { + await handleVerifyEvidence(args); + return; + } const recovered = await recoverInterruptedTransactions(); if (recovered.length && !hasFlag(args, "--json")) { for (const item of recovered) { @@ -266,11 +305,12 @@ async function main(): Promise { case "rollback": await handleRollback(args); break; case "history": await handleHistory(args); break; case "replay": await handleReplay(args); break; + case "evidence": await handleEvidence(args); break; case "report": await handleReport(args); break; case "doctor": await handleDoctor(args); break; case "demo": await handleDemo(args); break; case "--version": - case "-v": print("0.1.0"); break; + case "-v": print(VERSION); break; case "help": case "--help": case "-h": print(help()); break; diff --git a/src/core/evidence.ts b/src/core/evidence.ts new file mode 100644 index 0000000..8902101 --- /dev/null +++ b/src/core/evidence.ts @@ -0,0 +1,461 @@ +import { createHash } from "node:crypto"; +import { link, mkdir, open, readFile, rm } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { VERSION } from "../version.js"; +import { EventLedger, eventDigest } from "./ledger.js"; +import type { + DiffSummary, + EvidenceVerification, + RollbackEvidence, + RollbackReceipt, + TransactionEvent, + TransactionMetadata +} from "./types.js"; + +const SHA256 = /^[a-f0-9]{64}$/; +const GIT_OBJECT_ID = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/; +const TRANSACTION_ID = /^atx_[0-9]{8}_[0-9]{6}_[a-f0-9]{4,16}$/; +const SEMVER = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; +const WORKSPACE_ALGORITHM = "sha256(agenttx-git-visible-content-v1)" as const; +const CANONICAL_ALGORITHM = "sha256(agenttx-canonical-json-v1)" as const; + +interface RollbackCompletedData { + filesDiscarded: number; + workspaceStatusBefore: string | null; + workspaceStatusAfter: string | null; + diffSha256: string; + transactionSha256: string; +} + +export function canonicalStringify(value: unknown): string { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("Canonical JSON cannot encode a non-finite number."); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalStringify).join(",")}]`; + if (typeof value === "object") { + const entries = Object.entries(value as Record).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0 + ); + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalStringify(item)}`).join(",")}}`; + } + throw new Error(`Canonical JSON cannot encode ${typeof value}.`); +} + +export function canonicalSha256(value: unknown): string { + return createHash("sha256").update(canonicalStringify(value)).digest("hex"); +} + +export function deriveWorkspaceStatusUnchanged( + before: string | null, + after: string | null +): boolean | null { + return before === null || after === null ? null : before === after; +} + +export function rollbackTransactionRecord( + metadata: TransactionMetadata +): RollbackReceipt["transaction"] { + if (metadata.status !== "ROLLED_BACK" || !metadata.completedAt) { + throw new Error(`Transaction ${metadata.transactionId} is not a completed rollback.`); + } + if (!TRANSACTION_ID.test(metadata.transactionId)) throw new Error("Invalid transaction ID in metadata."); + if (!GIT_OBJECT_ID.test(metadata.baselineCommit) || !GIT_OBJECT_ID.test(metadata.baseHead)) { + throw new Error("Invalid Git object ID in transaction metadata."); + } + assertIsoTimestamp(metadata.completedAt, "transaction completedAt"); + return { + transactionId: metadata.transactionId, + baselineCommit: metadata.baselineCommit, + baseHead: metadata.baseHead, + state: "ROLLED_BACK", + completedAt: metadata.completedAt + }; +} + +function rollbackCompletedData(event: TransactionEvent | undefined): RollbackCompletedData { + if (!event || event.type !== "rollback.completed") { + throw new Error("Transaction ledger has no terminal rollback event."); + } + const data = event.data as Partial; + if ( + !isNonnegativeInteger(data.filesDiscarded) || + !isDigestOrNull(data.workspaceStatusBefore) || + !isDigestOrNull(data.workspaceStatusAfter) || + !isDigest(data.diffSha256) || + !isDigest(data.transactionSha256) + ) { + throw new Error("Terminal rollback event is missing verifiable evidence fields."); + } + return data as RollbackCompletedData; +} + +export async function buildRollbackEvidence(metadata: TransactionMetadata): Promise { + const transaction = rollbackTransactionRecord(metadata); + const [diff, events] = await Promise.all([ + readFile(join(metadata.transactionDirectory, "after.json"), "utf8").then( + (value) => JSON.parse(value) as DiffSummary + ), + new EventLedger(metadata.transactionDirectory).read() + ]); + const terminalEvent = events.at(-1); + if (!terminalEvent) throw new Error("Transaction ledger is empty."); + const terminal = rollbackCompletedData(terminalEvent); + const diffSha256 = createHash("sha256").update(JSON.stringify(diff)).digest("hex"); + const transactionSha256 = canonicalSha256(transaction); + if (terminal.diffSha256 !== diffSha256 || terminal.filesDiscarded !== diff.filesChanged) { + throw new Error("Transaction diff does not match the terminal rollback event."); + } + if (terminal.transactionSha256 !== transactionSha256) { + throw new Error("Transaction metadata does not match the terminal rollback event."); + } + const { hash: finalHash, ...terminalWithoutHash } = terminalEvent; + const receipt: RollbackReceipt = { + schemaVersion: 1, + evidenceType: "agenttx.rollback", + producer: { + repository: "aliengineering-byte/agenttx", + version: VERSION, + capability: "repository-transaction-rollback", + documentation: "https://github.com/aliengineering-byte/agenttx/blob/main/docs/SCHEMAS.md#rollback-evidence" + }, + transaction, + result: { + filesDiscarded: diff.filesChanged, + additionsDiscarded: diff.additions, + deletionsDiscarded: diff.deletions, + binaryFilesDiscarded: diff.binaryFiles, + originalWorkspaceStatusUnchanged: deriveWorkspaceStatusUnchanged( + terminal.workspaceStatusBefore, + terminal.workspaceStatusAfter + ) + }, + workspaceStatusEvidence: { + algorithm: WORKSPACE_ALGORITHM, + before: terminal.workspaceStatusBefore, + after: terminal.workspaceStatusAfter + }, + eventChain: { + algorithm: "sha256(JSON.stringify(event))", + events: events.length, + finalHash, + terminalEvent: terminalWithoutHash + }, + artifacts: { + transactionDiff: { + algorithm: "sha256(JSON.stringify(diff))", + sha256: diffSha256 + }, + transactionMetadata: { + algorithm: CANONICAL_ALGORITHM, + sha256: transactionSha256 + } + }, + redaction: { + filePathsIncluded: false, + commandArgumentsIncluded: false, + privatePathsIncluded: false, + secrets: "redacted" + }, + limitations: [ + "The workspace digest commits to Git HEAD, status, tracked diffs, and untracked content fingerprints; ignored files and external systems remain outside this proof.", + "AgentTX is repository isolation, not an operating-system security boundary.", + "This unsigned, recomputable integrity receipt is not authentication or proof against a party able to rewrite all local evidence." + ] + }; + return { + receipt, + integrity: { + algorithm: "sha256", + canonicalization: "agenttx-canonical-json-v1", + scope: "receipt", + authentication: "none", + digest: canonicalSha256(receipt) + } + }; +} + +export function verifyRollbackEvidence(value: unknown): EvidenceVerification { + const outer = record(value, "evidence artifact"); + exactKeys(outer, ["receipt", "integrity"], "evidence artifact"); + const receipt = record(outer.receipt, "receipt"); + const integrity = record(outer.integrity, "integrity"); + exactKeys( + integrity, + ["algorithm", "authentication", "canonicalization", "digest", "scope"], + "integrity" + ); + requireEqual(integrity.algorithm, "sha256", "integrity algorithm"); + requireEqual(integrity.canonicalization, "agenttx-canonical-json-v1", "canonicalization"); + requireEqual(integrity.scope, "receipt", "integrity scope"); + requireEqual(integrity.authentication, "none", "authentication"); + const outerDigest = digest(integrity.digest, "integrity digest"); + if (canonicalSha256(receipt) !== outerDigest) throw new Error("Evidence receipt digest mismatch."); + + exactKeys( + receipt, + [ + "artifacts", + "eventChain", + "evidenceType", + "limitations", + "producer", + "redaction", + "result", + "schemaVersion", + "transaction", + "workspaceStatusEvidence" + ], + "receipt" + ); + requireEqual(receipt.schemaVersion, 1, "receipt schemaVersion"); + requireEqual(receipt.evidenceType, "agenttx.rollback", "evidenceType"); + + const producer = record(receipt.producer, "producer"); + exactKeys(producer, ["capability", "documentation", "repository", "version"], "producer"); + requireEqual(producer.repository, "aliengineering-byte/agenttx", "producer repository"); + requireEqual(producer.capability, "repository-transaction-rollback", "producer capability"); + requireEqual( + producer.documentation, + "https://github.com/aliengineering-byte/agenttx/blob/main/docs/SCHEMAS.md#rollback-evidence", + "producer documentation" + ); + if (typeof producer.version !== "string" || !SEMVER.test(producer.version)) { + throw new Error("Invalid producer version."); + } + + const transaction = record(receipt.transaction, "transaction"); + exactKeys(transaction, ["baseHead", "baselineCommit", "completedAt", "state", "transactionId"], "transaction"); + const transactionId = stringMatching(transaction.transactionId, TRANSACTION_ID, "transactionId"); + stringMatching(transaction.baselineCommit, GIT_OBJECT_ID, "baselineCommit"); + stringMatching(transaction.baseHead, GIT_OBJECT_ID, "baseHead"); + requireEqual(transaction.state, "ROLLED_BACK", "transaction state"); + assertIsoTimestamp(transaction.completedAt, "transaction completedAt"); + + const result = record(receipt.result, "result"); + exactKeys( + result, + [ + "additionsDiscarded", + "binaryFilesDiscarded", + "deletionsDiscarded", + "filesDiscarded", + "originalWorkspaceStatusUnchanged" + ], + "result" + ); + for (const field of ["filesDiscarded", "additionsDiscarded", "deletionsDiscarded", "binaryFilesDiscarded"]) { + nonnegativeInteger(result[field], `result ${field}`); + } + + const workspace = record(receipt.workspaceStatusEvidence, "workspaceStatusEvidence"); + exactKeys(workspace, ["after", "algorithm", "before"], "workspaceStatusEvidence"); + requireEqual(workspace.algorithm, WORKSPACE_ALGORITHM, "workspace algorithm"); + const before = digestOrNull(workspace.before, "workspace before digest"); + const after = digestOrNull(workspace.after, "workspace after digest"); + if (result.originalWorkspaceStatusUnchanged !== deriveWorkspaceStatusUnchanged(before, after)) { + throw new Error("Workspace unchanged result is not derived from its before/after digests."); + } + + const artifacts = record(receipt.artifacts, "artifacts"); + exactKeys(artifacts, ["transactionDiff", "transactionMetadata"], "artifacts"); + const transactionDiff = record(artifacts.transactionDiff, "transactionDiff"); + exactKeys(transactionDiff, ["algorithm", "sha256"], "transactionDiff"); + requireEqual(transactionDiff.algorithm, "sha256(JSON.stringify(diff))", "diff algorithm"); + const diffSha256 = digest(transactionDiff.sha256, "transaction diff digest"); + const transactionMetadata = record(artifacts.transactionMetadata, "transactionMetadata"); + exactKeys(transactionMetadata, ["algorithm", "sha256"], "transactionMetadata"); + requireEqual(transactionMetadata.algorithm, CANONICAL_ALGORITHM, "metadata algorithm"); + const transactionSha256 = digest(transactionMetadata.sha256, "transaction metadata digest"); + if (canonicalSha256(transaction) !== transactionSha256) { + throw new Error("Transaction metadata digest mismatch."); + } + + const eventChain = record(receipt.eventChain, "eventChain"); + exactKeys(eventChain, ["algorithm", "events", "finalHash", "terminalEvent"], "eventChain"); + requireEqual(eventChain.algorithm, "sha256(JSON.stringify(event))", "event algorithm"); + const events = positiveInteger(eventChain.events, "event count"); + const finalHash = digest(eventChain.finalHash, "final event hash"); + const terminal = record(eventChain.terminalEvent, "terminalEvent"); + exactKeys(terminal, ["data", "previousHash", "schemaVersion", "seq", "timestamp", "type"], "terminalEvent"); + requireEqual(terminal.schemaVersion, 1, "terminal event schemaVersion"); + const sequence = positiveInteger(terminal.seq, "terminal event sequence"); + if (sequence !== events) throw new Error("Terminal event sequence does not match event count."); + requireEqual(terminal.type, "rollback.completed", "terminal event type"); + assertIsoTimestamp(terminal.timestamp, "terminal event timestamp"); + const previousHash = digestOrNull(terminal.previousHash, "terminal previous hash"); + if ((sequence === 1) !== (previousHash === null)) { + throw new Error("Terminal previous hash is inconsistent with its sequence."); + } + const terminalData = record(terminal.data, "terminal event data"); + exactKeys( + terminalData, + ["diffSha256", "filesDiscarded", "transactionSha256", "workspaceStatusAfter", "workspaceStatusBefore"], + "terminal event data" + ); + if (nonnegativeInteger(terminalData.filesDiscarded, "terminal filesDiscarded") !== result.filesDiscarded) { + throw new Error("Terminal discarded-file count does not match the receipt."); + } + if (digest(terminalData.diffSha256, "terminal diff digest") !== diffSha256) { + throw new Error("Terminal diff digest does not match the receipt."); + } + if (digest(terminalData.transactionSha256, "terminal metadata digest") !== transactionSha256) { + throw new Error("Terminal metadata digest does not match the receipt."); + } + if ( + digestOrNull(terminalData.workspaceStatusBefore, "terminal workspace before") !== before || + digestOrNull(terminalData.workspaceStatusAfter, "terminal workspace after") !== after + ) { + throw new Error("Terminal workspace digests do not match the receipt."); + } + const eventWithoutHash: Omit = { + schemaVersion: 1, + seq: sequence, + type: "rollback.completed", + timestamp: terminal.timestamp as string, + data: { + filesDiscarded: result.filesDiscarded, + diffSha256, + transactionSha256, + workspaceStatusBefore: before, + workspaceStatusAfter: after + }, + previousHash + }; + if (eventDigest(eventWithoutHash) !== finalHash) throw new Error("Terminal event digest mismatch."); + + const redaction = record(receipt.redaction, "redaction"); + exactKeys(redaction, ["commandArgumentsIncluded", "filePathsIncluded", "privatePathsIncluded", "secrets"], "redaction"); + requireEqual(redaction.filePathsIncluded, false, "filePathsIncluded"); + requireEqual(redaction.commandArgumentsIncluded, false, "commandArgumentsIncluded"); + requireEqual(redaction.privatePathsIncluded, false, "privatePathsIncluded"); + requireEqual(redaction.secrets, "redacted", "secret redaction"); + if ( + !Array.isArray(receipt.limitations) || + receipt.limitations.length < 3 || + receipt.limitations.some((item) => typeof item !== "string" || !item) + ) { + throw new Error("Evidence limitations are incomplete."); + } + if (!receipt.limitations.some((item) => (item as string).includes("unsigned, recomputable"))) { + throw new Error("Evidence must disclose its unsigned, recomputable integrity limitation."); + } + + return { + valid: true, + evidenceType: "agenttx.rollback", + transactionId, + digest: outerDigest, + authentication: "none" + }; +} + +export async function verifyRollbackEvidenceFile(path: string): Promise { + let value: unknown; + try { + value = JSON.parse(await readFile(resolve(path), "utf8")); + } catch (error) { + throw new Error(`Cannot read evidence file: ${(error as Error).message}`); + } + return verifyRollbackEvidence(value); +} + +export async function writeRollbackEvidence( + metadata: TransactionMetadata, + outputPath?: string +): Promise { + const destination = resolve(outputPath ?? join(metadata.transactionDirectory, "rollback-evidence.json")); + const contents = `${JSON.stringify(await buildRollbackEvidence(metadata), null, 2)}\n`; + try { + const existing = await readFile(destination, "utf8"); + if (existing === contents) return destination; + throw new Error(`Refusing to overwrite different rollback evidence: ${destination}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await mkdir(dirname(destination), { recursive: true }); + const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`; + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(contents); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await link(temporary, destination); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const existing = await readFile(destination, "utf8"); + if (existing !== contents) { + throw new Error(`Refusing to overwrite different rollback evidence: ${destination}`); + } + } finally { + await rm(temporary, { force: true }); + } + return destination; +} + +function record(value: unknown, name: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid ${name}.`); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], name: string): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`Unexpected fields in ${name}.`); + } +} + +function requireEqual(actual: unknown, expected: unknown, name: string): void { + if (actual !== expected) throw new Error(`Invalid ${name}.`); +} + +function stringMatching(value: unknown, pattern: RegExp, name: string): string { + if (typeof value !== "string" || !pattern.test(value)) throw new Error(`Invalid ${name}.`); + return value; +} + +function isDigest(value: unknown): value is string { + return typeof value === "string" && SHA256.test(value); +} + +function digest(value: unknown, name: string): string { + if (!isDigest(value)) throw new Error(`Invalid ${name}.`); + return value; +} + +function isDigestOrNull(value: unknown): value is string | null { + return value === null || isDigest(value); +} + +function digestOrNull(value: unknown, name: string): string | null { + if (!isDigestOrNull(value)) throw new Error(`Invalid ${name}.`); + return value; +} + +function isNonnegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function nonnegativeInteger(value: unknown, name: string): number { + if (!isNonnegativeInteger(value)) throw new Error(`Invalid ${name}.`); + return value; +} + +function positiveInteger(value: unknown, name: string): number { + const integer = nonnegativeInteger(value, name); + if (integer === 0) throw new Error(`Invalid ${name}.`); + return integer; +} + +function assertIsoTimestamp(value: unknown, name: string): asserts value is string { + if (typeof value !== "string" || Number.isNaN(Date.parse(value)) || new Date(value).toISOString() !== value) { + throw new Error(`Invalid ${name}.`); + } +} diff --git a/src/core/ledger.ts b/src/core/ledger.ts index d9b5bdb..f518418 100644 --- a/src/core/ledger.ts +++ b/src/core/ledger.ts @@ -5,7 +5,7 @@ import { pathExists } from "./fs.js"; import { redactValue } from "./redaction.js"; import { SCHEMA_VERSION, type TransactionEvent } from "./types.js"; -function eventDigest(event: Omit): string { +export function eventDigest(event: Omit): string { return createHash("sha256").update(JSON.stringify(event)).digest("hex"); } diff --git a/src/core/types.ts b/src/core/types.ts index 539223b..254b1ce 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -154,3 +154,75 @@ export interface TransactionInspection { eventCount: number; commandCount: number; } + +export interface RollbackReceipt { + schemaVersion: typeof SCHEMA_VERSION; + evidenceType: "agenttx.rollback"; + producer: { + repository: "aliengineering-byte/agenttx"; + version: string; + capability: "repository-transaction-rollback"; + documentation: string; + }; + transaction: { + transactionId: string; + baselineCommit: string; + baseHead: string; + state: "ROLLED_BACK"; + completedAt: string; + }; + result: { + filesDiscarded: number; + additionsDiscarded: number; + deletionsDiscarded: number; + binaryFilesDiscarded: number; + originalWorkspaceStatusUnchanged: boolean | null; + }; + workspaceStatusEvidence: { + algorithm: "sha256(agenttx-git-visible-content-v1)"; + before: string | null; + after: string | null; + }; + eventChain: { + algorithm: "sha256(JSON.stringify(event))"; + events: number; + finalHash: string; + terminalEvent: Omit; + }; + artifacts: { + transactionDiff: { + algorithm: "sha256(JSON.stringify(diff))"; + sha256: string; + }; + transactionMetadata: { + algorithm: "sha256(agenttx-canonical-json-v1)"; + sha256: string; + }; + }; + redaction: { + filePathsIncluded: false; + commandArgumentsIncluded: false; + privatePathsIncluded: false; + secrets: "redacted"; + }; + limitations: string[]; +} + +export interface RollbackEvidence { + receipt: RollbackReceipt; + integrity: { + algorithm: "sha256"; + canonicalization: "agenttx-canonical-json-v1"; + scope: "receipt"; + authentication: "none"; + digest: string; + }; +} + +export interface EvidenceVerification { + valid: true; + evidenceType: "agenttx.rollback"; + transactionId: string; + digest: string; + authentication: "none"; +} diff --git a/src/core/workspace.ts b/src/core/workspace.ts index 5325743..8f3172e 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -1,6 +1,13 @@ +import { createHash } from "node:crypto"; import { mkdir, readFile, realpath, rm } from "node:fs/promises"; import { basename, isAbsolute, join, relative, resolve } from "node:path"; import { EventLedger } from "./ledger.js"; +import { + canonicalSha256, + deriveWorkspaceStatusUnchanged, + rollbackTransactionRecord, + writeRollbackEvidence +} from "./evidence.js"; import { assertContained, assertSafeRelativePath, copyEntry, fingerprintPath, fingerprintsEqual, pathExists, toPosixPath, writeJsonAtomic } from "./fs.js"; import { findRepository, runGit, splitNull } from "./git.js"; import { redactText, sanitizeCommand } from "./redaction.js"; @@ -63,6 +70,31 @@ async function captureBefore(repositoryRoot: string, head: string): Promise { + try { + const [head, status, trackedDiff, untrackedOutput] = await Promise.all([ + runGit(repositoryRoot, ["rev-parse", "HEAD"]), + runGit(repositoryRoot, ["status", "--porcelain=v2", "-z", "--untracked-files=all"]), + runGit(repositoryRoot, ["diff", "--binary", "--full-index", "--no-ext-diff", "--no-textconv", "HEAD", "--"]), + runGit(repositoryRoot, ["ls-files", "-z", "--others", "--exclude-standard"]) + ]); + const untracked = await Promise.all( + splitNull(untrackedOutput.stdout) + .sort() + .map(async (path) => ({ path, fingerprint: await fingerprintPath(join(repositoryRoot, path)) })) + ); + return canonicalSha256({ + format: "agenttx-git-visible-content-v1", + head: head.stdout.trim(), + status: status.stdout, + trackedDiff: trackedDiff.stdout, + untracked + }); + } catch { + return null; + } +} + async function validateRepository(repositoryRoot: string): Promise { const bare = (await runGit(repositoryRoot, ["rev-parse", "--is-bare-repository"])).stdout.trim(); if (bare === "true") throw new Error("AgentTX cannot run in a bare Git repository."); @@ -482,11 +514,16 @@ export async function commitTransaction(metadata: TransactionMetadata): Promise< export async function rollbackTransaction(metadata: TransactionMetadata): Promise<{ metadata: TransactionMetadata; diff: DiffSummary; + evidencePath: string | null; + evidenceWarning: string | null; + originalWorkspaceStatusUnchanged: boolean | null; }> { if (!["CREATED", "REVIEW", "FAILED", "ABORTED"].includes(metadata.status)) { throw new Error(`Transaction ${metadata.transactionId} is ${metadata.status} and cannot be rolled back.`); } const diff = await inspectDiff(metadata); + await writeJsonAtomic(transactionPath(metadata, "after.json"), diff); + const workspaceStatusBefore = await workspaceStatusDigest(metadata.repositoryRoot); const ledger = new EventLedger(metadata.transactionDirectory); await ledger.append("rollback.started", { filesChanged: diff.filesChanged }); try { @@ -498,11 +535,33 @@ export async function rollbackTransaction(metadata: TransactionMetadata): Promis metadata = await transitionTransaction(metadata, "ROLLED_BACK", { completedAt: new Date().toISOString() }); + const workspaceStatusAfter = await workspaceStatusDigest(metadata.repositoryRoot); + const originalWorkspaceStatusUnchanged = deriveWorkspaceStatusUnchanged( + workspaceStatusBefore, + workspaceStatusAfter + ); + const transactionSha256 = canonicalSha256(rollbackTransactionRecord(metadata)); await ledger.append("rollback.completed", { filesDiscarded: diff.filesChanged, - originalWorkspaceChanged: false + diffSha256: createHash("sha256").update(JSON.stringify(diff)).digest("hex"), + transactionSha256, + workspaceStatusBefore, + workspaceStatusAfter }); - return { metadata, diff }; + let evidencePath: string | null = null; + let evidenceWarning: string | null = null; + try { + evidencePath = await writeRollbackEvidence(metadata); + } catch (error) { + evidenceWarning = redactText((error as Error).message); + } + return { + metadata, + diff, + evidencePath, + evidenceWarning, + originalWorkspaceStatusUnchanged + }; } export function processIsRunning(pid: number | undefined): boolean { diff --git a/src/index.ts b/src/index.ts index 8b18ffe..10bf4f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,9 @@ export type { FileFingerprint, RiskAssessment, RiskLevel, + RollbackEvidence, + RollbackReceipt, + EvidenceVerification, SideEffectFinding, TransactionEvent, TransactionInspection, @@ -16,7 +19,16 @@ export type { VerificationReport } from "./core/types.js"; export { EventLedger } from "./core/ledger.js"; +export { + buildRollbackEvidence, + canonicalSha256, + deriveWorkspaceStatusUnchanged, + verifyRollbackEvidence, + verifyRollbackEvidenceFile, + writeRollbackEvidence +} from "./core/evidence.js"; export { detectSideEffect } from "./detectors/side-effects.js"; export { inspectTransaction } from "./core/inspection.js"; export { assessRisk } from "./core/risk.js"; export { createTransaction, commitTransaction, rollbackTransaction } from "./core/workspace.js"; +export { VERSION } from "./version.js"; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..edbab61 --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const VERSION = "0.2.0"; diff --git a/tests/integration/security.test.ts b/tests/integration/security.test.ts index efb7aa7..099f1c2 100644 --- a/tests/integration/security.test.ts +++ b/tests/integration/security.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { beforeEach, describe, expect, it } from "vitest"; @@ -10,7 +10,7 @@ import { EventLedger } from "../../src/core/ledger.js"; import { generateHtmlReport } from "../../src/reporters/html.js"; import { renderTransactionReport } from "../../src/reporters/terminal.js"; import type { CommandSpec } from "../../src/core/types.js"; -import { builtCli, createRepository, isolatedHome } from "../helpers.js"; +import { builtCli, createRepository, isolatedHome, runNodeTransaction } from "../helpers.js"; const execFileAsync = promisify(execFile); @@ -128,4 +128,54 @@ describe("side-effect gating and privacy", () => { const value = JSON.parse(result.stdout) as { schemaVersion: number; transactionId: string }; expect(value).toMatchObject({ schemaVersion: 1, transactionId: created.transactionId }); }); + + it("reports rollback proof and regenerates evidence from the terminal ledger", async () => { + const repository = await createRepository(); + const metadata = await runNodeTransaction( + repository, + `require('node:fs').writeFileSync('file.txt','agent\\n')` + ); + const rollback = await execFileAsync( + process.execPath, + [builtCli, "rollback", metadata.transactionId], + { cwd: repository, env: process.env, encoding: "utf8", windowsHide: true } + ); + expect(rollback.stdout).toContain("Git-visible original workspace status unchanged (digest matched)."); + expect(rollback.stdout).toContain("Rollback evidence:"); + const evidencePath = join(metadata.transactionDirectory, "rollback-evidence.json"); + await rm(evidencePath); + const regenerated = await execFileAsync( + process.execPath, + [builtCli, "evidence", metadata.transactionId], + { cwd: repository, env: process.env, encoding: "utf8", windowsHide: true } + ); + expect(regenerated.stdout).toContain("Rollback evidence written to"); + const evidence = JSON.parse(await readFile(evidencePath, "utf8")) as { + receipt: { evidenceType: string; transaction: { state: string }; result: { filesDiscarded: number } }; + }; + expect(evidence.receipt).toMatchObject({ + evidenceType: "agenttx.rollback", + transaction: { state: "ROLLED_BACK" } + }); + const verified = await execFileAsync( + process.execPath, + [builtCli, "verify-evidence", evidencePath], + { cwd: repository, env: process.env, encoding: "utf8", windowsHide: true } + ); + expect(verified.stdout).toContain("Evidence integrity verified"); + expect(verified.stdout).toContain("unsigned, recomputable integrity, not authentication"); + + evidence.receipt.result.filesDiscarded += 1; + await writeFile(evidencePath, `${JSON.stringify(evidence)}\n`); + await expect( + execFileAsync( + process.execPath, + [builtCli, "verify-evidence", evidencePath], + { cwd: repository, env: process.env, encoding: "utf8", windowsHide: true } + ) + ).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("Evidence receipt digest mismatch") + }); + }, 60_000); }); diff --git a/tests/integration/workspace.test.ts b/tests/integration/workspace.test.ts index b1ee000..8767d3e 100644 --- a/tests/integration/workspace.test.ts +++ b/tests/integration/workspace.test.ts @@ -1,11 +1,16 @@ -import { mkdtemp, readFile, readlink, rename, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { commitTransaction, inspectDiff, recoverInterruptedTransactions, rollbackTransaction } from "../../src/core/workspace.js"; +import { + canonicalSha256, + verifyRollbackEvidence, + writeRollbackEvidence +} from "../../src/core/evidence.js"; import { createTransaction } from "../../src/core/workspace.js"; import { transitionTransaction, writeMetadata } from "../../src/core/store.js"; -import type { CommandSpec } from "../../src/core/types.js"; +import type { CommandSpec, RollbackEvidence } from "../../src/core/types.js"; import { createRepository, git, isolatedHome, runNodeTransaction, text } from "../helpers.js"; beforeEach(async () => { @@ -29,6 +34,83 @@ describe("Git transaction workspace", () => { expect(await text(join(repository, "a.txt"))).toBe("before-a\n"); expect(await text(join(repository, "delete.txt"))).toBe("keep me\n"); expect((await git(repository, ["status", "--porcelain"])).trim()).toBe(""); + if (!result.evidencePath) throw new Error(result.evidenceWarning ?? "rollback evidence missing"); + const evidenceText = await readFile(result.evidencePath, "utf8"); + const evidence = JSON.parse(evidenceText) as RollbackEvidence; + expect(evidence.receipt).toMatchObject({ + evidenceType: "agenttx.rollback", + producer: { + repository: "aliengineering-byte/agenttx", + capability: "repository-transaction-rollback" + }, + transaction: { transactionId: metadata.transactionId, state: "ROLLED_BACK" }, + result: { filesDiscarded: 3, originalWorkspaceStatusUnchanged: true }, + redaction: { filePathsIncluded: false, privatePathsIncluded: false } + }); + expect(evidence.receipt.eventChain.finalHash).toMatch(/^[a-f0-9]{64}$/); + expect(verifyRollbackEvidence(evidence)).toMatchObject({ + valid: true, + transactionId: metadata.transactionId, + authentication: "none" + }); + const serialized = JSON.stringify(evidence); + expect(serialized).not.toContain(repository); + expect(serialized).not.toContain("a.txt"); + + const unsignedTamper = structuredClone(evidence); + unsignedTamper.receipt.result.filesDiscarded += 1; + expect(() => verifyRollbackEvidence(unsignedTamper)).toThrow("Evidence receipt digest mismatch."); + + const recomputedBooleanTamper = structuredClone(evidence); + recomputedBooleanTamper.receipt.result.originalWorkspaceStatusUnchanged = false; + recomputedBooleanTamper.integrity.digest = canonicalSha256(recomputedBooleanTamper.receipt); + expect(() => verifyRollbackEvidence(recomputedBooleanTamper)).toThrow( + "Workspace unchanged result is not derived from its before/after digests." + ); + + const recomputedMetadataTamper = structuredClone(evidence); + recomputedMetadataTamper.receipt.transaction.baseHead = "0".repeat(40); + recomputedMetadataTamper.integrity.digest = canonicalSha256(recomputedMetadataTamper.receipt); + expect(() => verifyRollbackEvidence(recomputedMetadataTamper)).toThrow( + "Transaction metadata digest mismatch." + ); + expect(await writeRollbackEvidence(result.metadata)).toBe(result.evidencePath); + const occupied = join(metadata.transactionDirectory, "occupied-evidence.json"); + await writeFile(occupied, "do not replace\n"); + await expect(writeRollbackEvidence(result.metadata, occupied)).rejects.toThrow( + "Refusing to overwrite different rollback evidence" + ); + expect(await readFile(occupied, "utf8")).toBe("do not replace\n"); + await rm(result.evidencePath); + const regenerated = await writeRollbackEvidence(result.metadata); + expect(await readFile(regenerated, "utf8")).toBe(evidenceText); + const diffPath = join(metadata.transactionDirectory, "after.json"); + const tamperedDiff = JSON.parse(await readFile(diffPath, "utf8")) as { filesChanged: number }; + tamperedDiff.filesChanged += 1; + await writeFile(diffPath, `${JSON.stringify(tamperedDiff)}\n`); + await expect(writeRollbackEvidence(result.metadata)).rejects.toThrow( + "Transaction diff does not match the terminal rollback event." + ); + }); + + it("marks workspace proof unavailable when the original repository cannot be inspected", async () => { + const repository = await createRepository(); + const metadata = await runNodeTransaction( + repository, + `require('node:fs').writeFileSync('file.txt','agent\\n')` + ); + await rm(repository, { recursive: true, force: true }); + const result = await rollbackTransaction(metadata); + expect(result.metadata.status).toBe("ROLLED_BACK"); + expect(result.originalWorkspaceStatusUnchanged).toBeNull(); + if (!result.evidencePath) throw new Error(result.evidenceWarning ?? "rollback evidence missing"); + const evidence = JSON.parse(await readFile(result.evidencePath, "utf8")) as RollbackEvidence; + expect(evidence.receipt.result.originalWorkspaceStatusUnchanged).toBeNull(); + expect(evidence.receipt.workspaceStatusEvidence).toEqual({ + before: null, + after: null, + algorithm: "sha256(agenttx-git-visible-content-v1)" + }); }); it.skipIf(process.platform === "win32")("canonicalizes a symlinked repository invocation path", async () => { diff --git a/tests/unit/evidence.test.ts b/tests/unit/evidence.test.ts new file mode 100644 index 0000000..22c3484 --- /dev/null +++ b/tests/unit/evidence.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalSha256, + canonicalStringify, + deriveWorkspaceStatusUnchanged +} from "../../src/core/evidence.js"; + +describe("evidence integrity primitives", () => { + it("canonicalizes object keys by code-unit order independent of insertion order", () => { + const first = { z: 1, A: { beta: true, alpha: false }, a: 2 }; + const second = { a: 2, A: { alpha: false, beta: true }, z: 1 }; + expect(canonicalStringify(first)).toBe(canonicalStringify(second)); + expect(canonicalSha256(first)).toBe(canonicalSha256(second)); + expect(canonicalStringify(first)).toBe('{"A":{"alpha":false,"beta":true},"a":2,"z":1}'); + }); + + it("derives workspace status exclusively from before and after digests", () => { + const digest = "a".repeat(64); + expect(deriveWorkspaceStatusUnchanged(digest, digest)).toBe(true); + expect(deriveWorkspaceStatusUnchanged(digest, "b".repeat(64))).toBe(false); + expect(deriveWorkspaceStatusUnchanged(null, digest)).toBeNull(); + }); +}); diff --git a/tests/unit/version.test.ts b/tests/unit/version.test.ts new file mode 100644 index 0000000..d5b1907 --- /dev/null +++ b/tests/unit/version.test.ts @@ -0,0 +1,10 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { VERSION } from "../../src/version.js"; + +describe("package identity", () => { + it("keeps the public code version aligned with package.json", async () => { + const manifest = JSON.parse(await readFile("package.json", "utf8")) as { version: string }; + expect(VERSION).toBe(manifest.version); + }); +});