From 520c7fcc1389af659eb3a9a982f639ce1371f223 Mon Sep 17 00:00:00 2001 From: Ali <268342250+aliengineering-byte@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:46:50 -0400 Subject: [PATCH] feat: add proof-carrying transaction mode --- .github/workflows/proof-gallery.yml | 56 +++++ .github/workflows/publish-npm.yml | 342 +++++-------------------- CHANGELOG.md | 9 +- README.md | 53 ++-- action.yml | 28 +++ action/index.js | 86 +++++++ docs/DEPENDENCIES.md | 2 +- docs/GITHUB_ACTION.md | 43 ++++ docs/PROOF_MODE.md | 77 ++++++ docs/RELEASE_CHECKLIST.md | 2 +- docs/releases/v0.3.0.md | 14 ++ package-lock.json | 4 +- package.json | 7 +- scripts/generate-proof-gallery.mjs | 144 +++++++++++ scripts/proof-demo.mjs | 111 ++++++++ scripts/public-adoption-snapshot.mjs | 45 ++++ src/cli.ts | 211 +++++++++++++++- src/core/runner.ts | 121 ++++++++- src/index.ts | 11 + src/proof/config.ts | 131 ++++++++++ src/proof/init.ts | 70 ++++++ src/proof/process.ts | 57 +++++ src/proof/render.ts | 108 ++++++++ src/proof/run.ts | 361 +++++++++++++++++++++++++++ src/proof/types.ts | 178 +++++++++++++ src/proof/verify.ts | 158 ++++++++++++ src/version.ts | 2 +- tests/integration/proof.test.ts | 184 ++++++++++++++ 28 files changed, 2301 insertions(+), 314 deletions(-) create mode 100644 .github/workflows/proof-gallery.yml create mode 100644 action.yml create mode 100644 action/index.js create mode 100644 docs/GITHUB_ACTION.md create mode 100644 docs/PROOF_MODE.md create mode 100644 docs/releases/v0.3.0.md create mode 100644 scripts/generate-proof-gallery.mjs create mode 100644 scripts/proof-demo.mjs create mode 100644 scripts/public-adoption-snapshot.mjs create mode 100644 src/proof/config.ts create mode 100644 src/proof/init.ts create mode 100644 src/proof/process.ts create mode 100644 src/proof/render.ts create mode 100644 src/proof/run.ts create mode 100644 src/proof/types.ts create mode 100644 src/proof/verify.ts create mode 100644 tests/integration/proof.test.ts diff --git a/.github/workflows/proof-gallery.yml b/.github/workflows/proof-gallery.yml new file mode 100644 index 0000000..b9076e3 --- /dev/null +++ b/.github/workflows/proof-gallery.yml @@ -0,0 +1,56 @@ +name: Proof Gallery + +on: + workflow_dispatch: + schedule: + - cron: "17 6 * * 1" + +permissions: + contents: read + +concurrency: + group: proof-gallery + cancel-in-progress: false + +jobs: + generate-and-deploy: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + cache: npm + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + - run: npm ci + - run: npm run build + - name: Install exact released evidence producers + run: | + npm install --global --ignore-scripts resilireplay@0.7.1 + python -m pip install --disable-pip-version-check phaseprobe==0.3.0 + - run: npm run gallery:generate -- --output gallery-site + - name: Generate aggregate public adoption snapshot without telemetry + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/public-adoption-snapshot.mjs gallery-site/adoption.json + - name: Enforce the exact three-case gallery + run: | + test "$(grep -c '
' gallery-site/index.html)" -eq 3 + test "$(find gallery-site -name proof.json -type f | wc -l)" -eq 3 + for proof in gallery-site/*/proof.json; do node dist/src/cli.js verify-proof "$proof"; done + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + with: + path: gallery-site + - id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 2ac4c16..454dba0 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,325 +1,113 @@ -name: Publish AgentTX 0.2.0 to npm +name: Publish AgentTX to npm on: - workflow_dispatch: + release: + types: [published] permissions: contents: read id-token: write concurrency: - group: npm-agenttx-0.2.0 + group: npm-agenttx-${{ github.event.release.tag_name }} cancel-in-progress: false -env: - RELEASE_TAG: v0.2.0 - RELEASE_VERSION: 0.2.0 - RELEASE_COMMIT: 7382c4f06863e684451da9c27111cd7c18dcc9ee - TARBALL_SHA256: 809fd289573e14c29d4b629049eb414a6c30d5e1f9a044fd7ed63c91323b9408 - CHECKSUM_SHA256: 7a1b25217f8494b3ccd75b9a9abe82a62030eb64de5fa75cdd0e689624a4d5f8 - jobs: - verify: - name: Verify source and immutable npm tarball - if: >- - github.repository == 'aliengineering-byte/agenttx' && - github.ref == 'refs/heads/main' + verify-and-publish: + if: github.repository == 'aliengineering-byte/agenttx' runs-on: ubuntu-24.04 timeout-minutes: 30 + environment: + name: npm + url: https://www.npmjs.com/package/agenttx/v/${{ github.event.release.tag_name }} + permissions: + contents: write + id-token: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ github.event.release.tag_name }} fetch-depth: 0 persist-credentials: false - - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: - node-version: "24" + node-version: 24 registry-url: https://registry.npmjs.org/ - package-manager-cache: false - - - name: Pin the OIDC-capable npm client - run: npm install --global npm@11.9.0 - - - name: Verify the protected tag and package identity + cache: npm + - run: npm install --global npm@11.9.0 + - name: Validate immutable release identity shell: bash + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | set -euo pipefail - git fetch --force --no-tags origin \ - "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = tag - test "$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" = "$RELEASE_COMMIT" - git merge-base --is-ancestor "$RELEASE_COMMIT" origin/main - node --input-type=module <<'NODE' - import manifest from "./package.json" with { type: "json" }; - if (manifest.name !== "agenttx") throw new Error("Package name mismatch"); - if (manifest.version !== "0.2.0") throw new Error("Package version mismatch"); - if (manifest.repository?.url !== "git+https://github.com/aliengineering-byte/agenttx.git") { - throw new Error("Public repository URL mismatch"); - } - if (manifest.publishConfig?.access !== "public") throw new Error("Public access mismatch"); - NODE - if npm view "agenttx@$RELEASE_VERSION" version >/dev/null 2>&1; then - echo "agenttx@$RELEASE_VERSION already exists; refusing to republish" >&2 - exit 1 - fi - - - name: Run the complete source and package verification suite - shell: bash + test "$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" = "$(git rev-parse HEAD)" + version="$(node -p "require('./package.json').version")" + test "$RELEASE_TAG" = "v$version" + test "$version" = "0.3.0" + - run: npm ci + - name: Run source, adversarial, and deterministic demo gates run: | - set -euo pipefail - npm ci npm run lint npm run typecheck - npm run build npm test npm run scan:secrets npm run check:links npm run release:verify - npm run demo - - - name: Download and verify the exact GitHub release tarball + npm run demo:proof + - name: Pack and inspect the exact npm payload shell: bash run: | set -euo pipefail - tarball="agenttx-$RELEASE_VERSION.tgz" - checksum="agenttx-$RELEASE_VERSION.sha256" - base="https://github.com/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG" - mkdir dist - curl --fail --location --proto '=https' --tlsv1.2 \ - --output "dist/$tarball" "$base/$tarball" - curl --fail --location --proto '=https' --tlsv1.2 \ - --output "dist/$checksum" "$base/$checksum" - printf '%s %s\n%s %s\n' \ - "$TARBALL_SHA256" "dist/$tarball" \ - "$CHECKSUM_SHA256" "dist/$checksum" | sha256sum --check --strict - (cd dist && sha256sum --check --strict "$checksum") - - - name: Inspect the publication payload and reject unsafe contents - shell: bash - run: | - set -euo pipefail - tarball="dist/agenttx-$RELEASE_VERSION.tgz" - python3 - "$tarball" <<'PY' - import pathlib - import re - import sys - import tarfile - - archive = tarfile.open(sys.argv[1], "r:gz") - members = archive.getmembers() - assert 1 <= len(members) <= 250 - assert sum(member.size for member in members) <= 10_000_000 - allowed_roots = { - "dist", "docs", "scripts", "CHANGELOG.md", "LICENSE", "README.md", - "SECURITY.md", "package.json", - } - blocked_parts = { - ".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", - "__pycache__", "node_modules", - } - patterns = [ - re.compile(rb"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"), - re.compile(rb"(?:ghp_|github_pat_|npm_)[A-Za-z0-9_]{30,}"), - re.compile(rb"pypi-[A-Za-z0-9_-]{20,}"), - re.compile(rb"AKIA[0-9A-Z]{16}"), - re.compile(rb"/" + rb"home/" + rb"runner/"), - re.compile(rb"/" + rb"Users/" + rb"[^/\s]+/"), - re.compile(rb"[A-Za-z]:" + rb"\\\\" + rb"Users\\\\" + rb"[^\\\s]+\\\\"), - ] - for member in members: - name = member.name - assert "\\" not in name, name - path = pathlib.PurePosixPath(name) - assert not path.is_absolute() and ".." not in path.parts, name - assert path.parts[0] == "package" and len(path.parts) >= 2, name - assert not blocked_parts.intersection(path.parts), name - assert path.parts[1] in allowed_roots, name - assert not name.startswith("package/src/"), name - assert pathlib.PurePosixPath(name).suffix.lower() not in { - ".key", ".p12", ".pem", ".pfx", - }, name - assert not member.issym() and not member.islnk(), name - if member.isfile(): - assert member.size <= 3_000_000, name - stream = archive.extractfile(member) - assert stream is not None - value = stream.read() - for pattern in patterns: - assert pattern.search(value) is None, (name, pattern.pattern) - PY - mkdir "$RUNNER_TEMP/package-inspect" - tar -xzf "$tarball" -C "$RUNNER_TEMP/package-inspect" - test "$(find "$RUNNER_TEMP/package-inspect/package" -type f | wc -l)" -le 250 - test "$(du -sb "$RUNNER_TEMP/package-inspect/package" | cut -f1)" -le 10000000 - test -z "$(find "$RUNNER_TEMP/package-inspect/package" -type l -print -quit)" - node --input-type=module - "$RUNNER_TEMP/package-inspect/package/package.json" <<'NODE' + mkdir .artifacts + npm pack --pack-destination .artifacts --json > .artifacts/pack.json + node --input-type=module <<'NODE' import { readFile } from "node:fs/promises"; - const manifest = JSON.parse(await readFile(process.argv[2], "utf8")); - if (manifest.name !== "agenttx" || manifest.version !== "0.2.0") { - throw new Error("Packed identity mismatch"); + const [packed] = JSON.parse(await readFile(".artifacts/pack.json", "utf8")); + if (packed.name !== "agenttx" || packed.version !== "0.3.0") throw new Error("Packed identity mismatch"); + const paths = packed.files.map((file) => file.path); + for (const required of ["dist/src/cli.js", "scripts/proof-demo.mjs", "docs/PROOF_MODE.md"]) { + if (!paths.includes(required)) throw new Error(`Missing ${required}`); } - if (manifest.repository?.url !== "git+https://github.com/aliengineering-byte/agenttx.git") { - throw new Error("Packed repository mismatch"); + if (paths.some((path) => path.startsWith("src/") || path.includes("node_modules") || path.includes(".env"))) { + throw new Error("Private build input entered the package"); } - if (manifest.bin?.agenttx !== "./dist/src/cli.js") throw new Error("CLI entry missing"); NODE - npm publish "$tarball" --access public --dry-run - - - name: Exercise rollback receipt verification and tamper rejection from the tarball - shell: bash - run: | - set -euo pipefail + sha256sum .artifacts/agenttx-0.3.0.tgz > .artifacts/agenttx-0.3.0.sha256 root="$(mktemp -d)" - prefix="$root/prefix" - repository="$root/repository" - export AGENTTX_HOME="$root/agenttx-home" - npm install --ignore-scripts --prefix "$prefix" "./dist/agenttx-$RELEASE_VERSION.tgz" - cli="$prefix/node_modules/agenttx/dist/src/cli.js" - mkdir "$repository" - cd "$repository" - printf 'before\n' > file.txt - printf "import { writeFileSync } from 'node:fs';\nwriteFileSync('file.txt', 'agent change\\n');\n" > agent.mjs - git init -q - git add -A - git -c user.name='AgentTX Registry Verification' \ - -c user.email='registry-verification@agenttx.invalid' \ - commit -q -m baseline - node "$cli" run node agent.mjs - node "$cli" rollback - test "$(cat file.txt)" = before - test -z "$(git status --porcelain)" - evidence="$(find "$AGENTTX_HOME" -name rollback-evidence.json -type f -print -quit)" - test -n "$evidence" - node "$cli" verify-evidence "$evidence" - tampered="$root/tampered-evidence.json" - node --input-type=module - "$evidence" "$tampered" <<'NODE' - import { readFile, writeFile } from "node:fs/promises"; - const value = JSON.parse(await readFile(process.argv[2], "utf8")); - value.receipt.result.filesDiscarded += 1; - await writeFile(process.argv[3], `${JSON.stringify(value)}\n`); - NODE - set +e - output="$(node "$cli" verify-evidence "$tampered" 2>&1)" - code=$? - set -e - test "$code" -eq 1 - grep -Fq 'Evidence receipt digest mismatch' <<<"$output" - - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: agenttx-0.2.0-verified-tarball - path: dist/agenttx-0.2.0.tgz - if-no-files-found: error - retention-days: 1 - - publish: - name: Publish through npm Trusted Publishing - needs: verify - runs-on: ubuntu-24.04 - timeout-minutes: 10 - environment: - name: npm - url: https://www.npmjs.com/package/agenttx/v/0.2.0 - steps: - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: "24" - registry-url: https://registry.npmjs.org/ - package-manager-cache: false - - run: npm install --global npm@11.9.0 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: agenttx-0.2.0-verified-tarball - path: dist - - run: npm publish dist/agenttx-0.2.0.tgz --access public - - verify-public: - name: Verify the public npm consumer path - needs: publish - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: "24" - registry-url: https://registry.npmjs.org/ - package-manager-cache: false - - name: Pin public verification clients - run: | - npm install --global npm@11.9.0 - npm install --global pnpm@10.14.0 - - name: Query npm and verify the exact public tarball and provenance + npm install --ignore-scripts --prefix "$root" .artifacts/agenttx-0.3.0.tgz + (cd "$root" && node node_modules/agenttx/scripts/proof-demo.mjs) + - name: Attach immutable package artifacts without replacement shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | set -euo pipefail - for attempt in {1..18}; do - if curl --fail --silent --show-error \ - "https://registry.npmjs.org/agenttx/$RELEASE_VERSION" \ - --output "$RUNNER_TEMP/npm.json"; then - break + existing="$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name')" + for asset in agenttx-0.3.0.tgz agenttx-0.3.0.sha256; do + if grep -Fxq "$asset" <<<"$existing"; then + echo "Refusing to replace immutable release asset $asset" >&2 + exit 1 fi - sleep 10 done - tarball_url="$(node --input-type=module - "$RUNNER_TEMP/npm.json" <<'NODE' - import { readFile } from "node:fs/promises"; - const value = JSON.parse(await readFile(process.argv[2], "utf8")); - if (value.name !== "agenttx" || value.version !== "0.2.0") throw new Error("Registry identity mismatch"); - if (!value.dist?.integrity?.startsWith("sha512-")) throw new Error("Registry integrity missing"); - if (value.dist?.attestations?.provenance?.predicateType !== "https://slsa.dev/provenance/v1") { - throw new Error("npm provenance attestation missing"); - } - process.stdout.write(value.dist.tarball); - NODE - )" - curl --fail --location --proto '=https' --tlsv1.2 \ - --output "$RUNNER_TEMP/agenttx-0.2.0.tgz" "$tarball_url" - printf '%s %s\n' "$TARBALL_SHA256" "$RUNNER_TEMP/agenttx-0.2.0.tgz" | \ - sha256sum --check --strict - test "$(npm view agenttx version)" = "$RELEASE_VERSION" - - - name: Run the exact one-command public demo - working-directory: ${{ runner.temp }} - run: pnpm dlx agenttx@0.2.0 demo - - - name: Verify npm signatures, rollback evidence, and tamper rejection + gh release upload "$RELEASE_TAG" .artifacts/agenttx-0.3.0.tgz .artifacts/agenttx-0.3.0.sha256 + - name: Publish through npm Trusted Publishing + run: npm publish .artifacts/agenttx-0.3.0.tgz --access public --provenance + - name: Verify the public package and proof path shell: bash run: | set -euo pipefail + for delay in 2 4 8 16 30 30; do + if test "$(npm view agenttx@0.3.0 version 2>/dev/null)" = 0.3.0; then break; fi + sleep "$delay" + done + test "$(npm view agenttx@0.3.0 version)" = 0.3.0 root="$(mktemp -d)" - consumer="$root/consumer" - repository="$root/repository" - export AGENTTX_HOME="$root/agenttx-home" - mkdir "$consumer" - cd "$consumer" - npm init --yes >/dev/null - npm install --ignore-scripts agenttx@0.2.0 + cd "$root" + npm install --ignore-scripts agenttx@0.3.0 npm audit signatures - cli="$consumer/node_modules/agenttx/dist/src/cli.js" - mkdir "$repository" - cd "$repository" - printf 'before\n' > file.txt - printf "import { writeFileSync } from 'node:fs';\nwriteFileSync('file.txt', 'registry change\\n');\n" > agent.mjs - git init -q - git add -A - git -c user.name='AgentTX Public Verification' \ - -c user.email='public-verification@agenttx.invalid' \ - commit -q -m baseline - node "$cli" run node agent.mjs - node "$cli" rollback - test "$(cat file.txt)" = before - test -z "$(git status --porcelain)" - evidence="$(find "$AGENTTX_HOME" -name rollback-evidence.json -type f -print -quit)" - test -n "$evidence" - node "$cli" verify-evidence "$evidence" - tampered="$root/tampered-evidence.json" - node --input-type=module - "$evidence" "$tampered" <<'NODE' - import { readFile, writeFile } from "node:fs/promises"; - const value = JSON.parse(await readFile(process.argv[2], "utf8")); - value.receipt.transaction.state = "COMMITTED"; - await writeFile(process.argv[3], `${JSON.stringify(value)}\n`); - NODE - if node "$cli" verify-evidence "$tampered"; then - echo "Tampered public rollback evidence was accepted" >&2 - exit 1 - fi + started="$(date +%s)" + node node_modules/agenttx/scripts/proof-demo.mjs --output demo + test "$(( $(date +%s) - started ))" -lt 60 diff --git a/CHANGELOG.md b/CHANGELOG.md index ca82d0b..dc57cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to AgentTX are documented here. The project follows Semantic ### Added +- `agenttx proof -- ` adds bounded, argv-first proof-carrying transactions with explicit required/optional validators, commit-on-success, rollback-on-failure, dry-run planning, privacy modes, and no-clobber output. +- Every proof pack contains a canonical `proof.json`, deterministic JavaScript-free `proof.html`, exact-argv `reproduce.md`, and bound related-evidence copies. +- `agenttx verify-proof` fails closed on receipt, derived-verdict, related-evidence, Proof Card, and reproduction tampering. +- A deterministic bad-agent/good-agent demonstration proves test-weakening rejection and completes without a model API. +- The root `action.yml` provides the least-privilege AgentTX Proof Verifier Action, with Job Summary output and paths suitable for immutable artifact upload. +- `agenttx init --github` creates a minimal proof workflow/config without overwriting, pushing, opening a PR, or changing repository settings. +- `agenttx feedback` displays the only fields included in a voluntary prefilled issue URL and never uploads or opens a browser. - 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. @@ -13,7 +20,7 @@ All notable changes to AgentTX are documented here. The project follows Semantic ### Changed -- Bump the unreleased package identity to `0.2.0`. +- Bump the unreleased package identity to `0.3.0` for the user-visible Proof Mode and GitHub Action. - Pin CI checkout and Node setup actions to reviewed immutable commits. ## [0.1.0] - 2026-08-08 diff --git a/README.md b/README.md index be109bd..101f4b7 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,24 @@ # AgentTX -**Make AI agents undoable.** +## Every AI change comes with proof. -**Git-style transactions for AI coding agents.** +Run any coding agent behind one generic command boundary. AgentTX isolates its Git-visible changes, runs your required validators, accepts only a derived success, and emits a machine-verifiable receipt plus a self-contained Proof Card. -Run an agent in an isolated repository transaction. Inspect everything it changed. Commit the good. Roll back the bad. +```bash +agenttx proof --validator '["npm","test"]' -- codex exec "fix the failing test without weakening it" +``` + +**PASS means the command and every required gate passed. A failed command, validator, or related-evidence check is rejected and rolled back by default.** + +[Open Proof Mode](docs/PROOF_MODE.md) · [Run the deterministic bad-agent demo](#proof-mode-demo) · [Verify in GitHub Actions](docs/GITHUB_ACTION.md) + +[View the CI-generated three-case Proof Gallery](https://aliengineering-byte.github.io/agenttx/). [![npm version](https://img.shields.io/npm/v/agenttx?logo=npm)](https://www.npmjs.com/package/agenttx) [![CI](https://github.com/aliengineering-byte/agenttx/actions/workflows/ci.yml/badge.svg)](https://github.com/aliengineering-byte/agenttx/actions/workflows/ci.yml) [![MIT License](https://img.shields.io/badge/license-MIT-3fb950.svg)](LICENSE) [![Node.js 20+](https://img.shields.io/badge/node-20%2B-339933.svg)](https://nodejs.org/) -![AgentTX real offline demo: an agent changes seven files, AgentTX gates a simulated push, reports high risk, and rolls the transaction back](docs/assets/agenttx-demo.gif) - -**Run → Inspect → Commit / Rollback** - -[Static demo frame](docs/assets/agenttx-demo.png) · [Plain-text transcript](docs/assets/terminal-demo.txt) - ## Quick start AgentTX requires Node.js 20+ and Git. Start inside a Git repository with at least one commit. @@ -24,38 +26,42 @@ AgentTX requires Node.js 20+ and Git. Start inside a Git repository with at leas ```bash npm install --global agenttx cd my-project -agenttx run +agenttx proof -- ``` -When the agent exits, review the transaction: +Proof Mode commits repository changes only when the command and every required validator pass. It otherwise rolls the isolated change back. Every terminal result includes `proof.json`, `proof.html`, and `reproduce.md`. + +Verify a copied proof pack offline: ```bash -agenttx diff -agenttx inspect +agenttx verify-proof path/to/proof.json ``` -Then accept its file changes—or discard the entire transaction: +Use classic review mode when you want a human decision instead: ```bash -agenttx commit -# or -agenttx rollback +agenttx run -- +agenttx diff +agenttx commit # or 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: +## Proof Mode demo + +Try the deterministic proof demonstration with no model, credentials, remote, or network write. A bad agent weakens a protected test, the policy gate rejects it, AgentTX restores the original state, and tampering is rejected. A good agent then fixes the defect while preserving the test and earns a passing proof. ```bash -agenttx demo +npm run build +npm run demo:proof ``` ## The transaction boundary 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.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). +> **Security boundary:** AgentTX v0.3.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? @@ -71,6 +77,11 @@ AgentTX captures the repository baseline, builds an independent local clone, ove | Command | Purpose | |---|---| +| `agenttx proof [options] -- ` | Gate a command, commit or roll back, and generate a verifiable proof pack | +| `agenttx verify-proof ` | Offline-check the receipt, related artifacts, Proof Card, and reproduction record | +| `agenttx render-proof [--output proof.html]` | Render a valid receipt as a self-contained Proof Card without overwriting files | +| `agenttx init --github` | Create a minimal proof config and least-privilege workflow without overwriting | +| `agenttx feedback ` | Show safe fields and print a voluntary issue URL without uploading or opening a browser | | `agenttx run [--allow-external] [--] ` | Run any command in a new transaction | | `agenttx status [id] [--json]` | Show transaction state | | `agenttx diff [id] [--stat\|--full]` | Review changed files or the redacted patch | @@ -86,7 +97,7 @@ AgentTX captures the repository baseline, builds an independent local clone, ove | `agenttx doctor [--json]` | Check Node, Git, repository state, storage, and agent CLIs | | `agenttx demo [--keep]` | Run the offline seven-file demo | -Machine consumers can use `status --json` and `inspect --json`. Their versioned examples are in [the schema reference](docs/SCHEMAS.md). +Machine consumers can use `proof --json`, `verify-proof --json`, `status --json`, and `inspect --json`. Their versioned examples are in [the schema reference](docs/SCHEMAS.md). ## Works around the agent, not instead of it diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..5c75885 --- /dev/null +++ b/action.yml @@ -0,0 +1,28 @@ +name: AgentTX Proof Verifier +description: Verify an AgentTX proof bundle, render its Proof Card, and fail closed on tampering. +author: aliengineering-byte +branding: + icon: shield + color: purple +inputs: + proof-json: + description: Path to proof.json, relative to the checked-out repository. + required: true + render-card: + description: Render proof.html from a valid receipt when it is missing. + required: false + default: "true" +outputs: + verdict: + description: Derived proof verdict. + receipt-digest: + description: SHA-256 digest of the canonical proof receipt. + proof-json-path: + description: Resolved proof.json path. + proof-card-path: + description: Resolved proof.html path. + transaction-state: + description: Terminal AgentTX transaction state. +runs: + using: node24 + main: action/index.js diff --git a/action/index.js b/action/index.js new file mode 100644 index 0000000..4016c55 --- /dev/null +++ b/action/index.js @@ -0,0 +1,86 @@ +import { execFile } from "node:child_process"; +import { appendFile, readFile } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const PACKAGE_VERSION = "0.3.0"; + +function fail(message) { + process.stderr.write(`::error title=AgentTX proof verification failed::${String(message).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A")}\n`); + process.exitCode = 1; +} + +function contained(root, child) { + const relation = relative(resolve(root), resolve(child)); + return relation === "" || (!relation.startsWith("..") && !isAbsolute(relation)); +} + +async function runCli(args) { + const override = process.env.AGENTTX_CLI_PATH; + if (override) return execFileAsync(process.execPath, [override, ...args], { windowsHide: true }); + const executable = process.platform === "win32" ? "npx.cmd" : "npx"; + return execFileAsync(executable, ["--yes", `--package=agenttx@${PACKAGE_VERSION}`, "--", "agenttx", ...args], { + windowsHide: true, + timeout: 120_000, + maxBuffer: 16 * 1024 * 1024, + shell: process.platform === "win32" + }); +} + +async function setOutputs(values) { + const target = process.env.GITHUB_OUTPUT; + if (!target) return; + const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n"); + await appendFile(target, `${lines}\n`); +} + +async function summary(proof, paths) { + const target = process.env.GITHUB_STEP_SUMMARY; + if (!target) return; + const source = "https://github.com/aliengineering-byte/agenttx"; + const markdown = `## AgentTX proof: ${proof.proof.transaction.verdict}\n\n` + + `- Transaction state: **${proof.proof.transaction.state}**\n` + + `- Receipt digest: \`${proof.integrity.digest}\`\n` + + `- Changed files: ${proof.proof.changes.filesChanged}\n` + + `- Required validators passed: ${proof.proof.claims.requiredValidatorsPassed ? "yes" : "no"}\n` + + `- Proof JSON: \`${paths.json}\`\n` + + `- Proof Card: \`${paths.card}\`\n` + + `- Producer: [AgentTX ${proof.proof.agenttxVersion}](${source})\n\n` + + `${proof.proof.transaction.reason}\n`; + await appendFile(target, markdown); +} + +async function main() { + const workspace = resolve(process.env.GITHUB_WORKSPACE ?? process.cwd()); + const input = process.env["INPUT_PROOF-JSON"]; + if (!input || isAbsolute(input)) throw new Error("proof-json must be a relative repository path."); + if (!/^[A-Za-z0-9._/\\ -]+$/.test(input)) { + throw new Error("proof-json contains characters that are unsafe for cross-platform command execution."); + } + const proofPath = resolve(workspace, input); + if (!contained(workspace, proofPath)) throw new Error("proof-json escapes the GitHub workspace."); + const cardPath = resolve(dirname(proofPath), "proof.html"); + if ((process.env["INPUT_RENDER-CARD"] ?? "true").toLowerCase() === "true") { + try { + await readFile(cardPath); + } catch (error) { + if (error.code !== "ENOENT") throw error; + await runCli(["render-proof", proofPath, "--output", cardPath]); + } + } + await runCli(["verify-proof", proofPath, "--json"]); + const proof = JSON.parse(await readFile(proofPath, "utf8")); + const values = { + verdict: proof.proof.transaction.verdict, + "receipt-digest": proof.integrity.digest, + "proof-json-path": proofPath, + "proof-card-path": cardPath, + "transaction-state": proof.proof.transaction.state + }; + await setOutputs(values); + await summary(proof, { json: proofPath, card: cardPath }); + process.stdout.write(`AgentTX proof verified: ${values.verdict} (${values["receipt-digest"]})\n`); +} + +main().catch((error) => fail(error instanceof Error ? error.message : error)); diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 72c46f8..162baa7 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -1,6 +1,6 @@ # Dependency and license record -AgentTX v0.2.0 has **zero runtime dependencies**. +AgentTX v0.3.0 has **zero runtime dependencies**. The GitHub Action invokes the exact public AgentTX npm verifier at runtime; the installed CLI and generated proof packs remain offline-verifiable. The release environment resolved these direct development dependencies from `package-lock.json`: diff --git a/docs/GITHUB_ACTION.md b/docs/GITHUB_ACTION.md new file mode 100644 index 0000000..2e97540 --- /dev/null +++ b/docs/GITHUB_ACTION.md @@ -0,0 +1,43 @@ +# AgentTX Proof Verifier Action + +`AgentTX Proof Verifier` verifies a complete proof pack using the exact AgentTX npm release, fails closed on any mismatch, writes a concise Job Summary, and exposes paths for immutable artifact upload. It requests no permissions itself. + +## Minimal workflow + +```yaml +name: Verify proof +on: workflow_dispatch +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - id: proof + uses: aliengineering-byte/agenttx@v0.3.0 + with: + proof-json: proof/proof.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: agenttx-proof + path: | + ${{ steps.proof.outputs.proof-json-path }} + ${{ steps.proof.outputs.proof-card-path }} +``` + +For a hardened workflow, replace `aliengineering-byte/agenttx@v0.3.0` with the immutable commit SHA associated with the signed-off v0.3.0 release. Do not guess or prefill that SHA before the release commit exists. + +Outputs are `verdict`, `receipt-digest`, `proof-json-path`, `proof-card-path`, and `transaction-state`. The Action does not run an arbitrary agent, write PR comments, open a browser, or request write/id-token permissions. + +`agenttx init --github` creates the minimal configuration and workflow only when both target files are absent. It does not push, open a PR, add a badge, or modify repository settings. `--badge owner/repository` prints—but does not write—an optional real workflow-status badge. + +## Release order + +1. Merge the reviewed code and pass the normal cross-platform CI matrix. +2. Create the immutable annotated Git tag and GitHub Release `v0.3.0` without moving earlier tags. +3. Let the release workflow publish `agenttx@0.3.0` with npm Trusted Publishing and provenance. +4. Run the independent downstream Action smoke repository against the release commit. +5. Edit the existing release, select **Publish this Action to the GitHub Marketplace** and the **Utilities** and **Continuous integration** categories, then publish only after any listing agreement/2FA owner boundary is satisfied. + +The Action downloads the exact `agenttx@0.3.0` verifier through npm when no local verifier override is provided. This keeps the Marketplace runtime aligned with the independently installable package. GitHub-hosted runners therefore need npm registry access for this Action version; receipt verification by the installed CLI itself remains fully offline. diff --git a/docs/PROOF_MODE.md b/docs/PROOF_MODE.md new file mode 100644 index 0000000..4ac2b27 --- /dev/null +++ b/docs/PROOF_MODE.md @@ -0,0 +1,77 @@ +# Proof Mode + +AgentTX Proof Mode wraps one argv-based command in an isolated repository transaction, runs explicit gates, derives the verdict, and writes a portable proof pack. + +```bash +agenttx proof --validator '["npm","test"]' -- codex exec "fix issue 123" +``` + +The three generated files are `proof.json`, the self-contained and JavaScript-free `proof.html`, and `reproduce.md`. Verify the complete bundle without a network connection: + +```bash +agenttx verify-proof path/to/proof.json +``` + +## Configuration + +For commands whose argument boundaries should not depend on shell quoting, use `.agenttx/proof.json`: + +```json +{ + "validators": [ + { + "id": "tests", + "argv": ["npm", "test"], + "required": true, + "shell": true, + "timeoutMs": 120000 + } + ], + "relatedEvidence": [] +} +``` + +`shell` defaults to `false`. Set it only for a validator that actually needs a platform shell. The wrapped command also runs without a shell unless `--shell` is explicit. Proof Mode preserves argv boundaries, caps execution time and captured output, refuses nested transactions, and never records the environment or prompts. + +Useful options: + +- `--optional-validator '["tool","arg"]'` records but does not gate an optional check. +- `--no-commit` leaves an accepted transaction in `REVIEW`. +- `--no-rollback` leaves rejected changes isolated for inspection. +- `--output-dir path` chooses a new, non-existing proof directory. +- `--privacy minimal` withholds changed paths and output previews. +- `--allow-external` explicitly accepts that the command may cause effects AgentTX cannot roll back; the receipt retains that limitation. +- `--dry-run --json` validates and prints the bounded execution plan without running the command. +- `--max-output-bytes`, `--max-evidence-bytes`, and `--timeout-ms` tighten resource bounds. + +## Related evidence + +Related evidence stays in its producer's schema. AgentTX records a typed reference, copies the verified artifact into the proof pack, and binds its bytes by SHA-256: + +```json +{ + "relatedEvidence": [ + { + "producer": "io.github.aliengineering-byte/resilireplay", + "version": "0.8.0", + "capability": "reliability-campaign", + "path": "artifacts/resilireplay-evidence.json", + "verify": ["resilireplay", "verify", "{evidence}"], + "required": true + } + ] +} +``` + +Failure to find or verify required related evidence rejects the transaction and triggers the configured rollback behavior. + +## Security boundary + +AgentTX isolates Git-visible repository changes. It is not an operating-system sandbox. A child retains the invoking user's permissions. AgentTX cannot undo remote pushes, emails, API calls, database writes, or other external side effects. Receipt integrity is unsigned and recomputable; it detects partial or accidental tampering but does not authenticate a party able to rewrite the entire artifact. + +Run the deterministic bad-agent/good-agent demonstration with no model account or paid API: + +```bash +npm run build +npm run demo:proof +``` diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index f4cfac8..17a0cf6 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.2.0` tag prepared from that exact commit +- [ ] annotated release tag prepared from that exact commit without moving an existing tag - [ ] package tarball SHA-256 recorded - [ ] GitHub repository publication verified - [ ] npm publication verified from the public registry diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md new file mode 100644 index 0000000..787b0f9 --- /dev/null +++ b/docs/releases/v0.3.0.md @@ -0,0 +1,14 @@ +# AgentTX 0.3.0 — Proof Mode + +Every AI change comes with proof. + +This minor release adds the argv-first `agenttx proof -- ` workflow, +derived commit-or-rollback decisions, portable `proof.json`, a self-contained +JavaScript-free Proof Card, exact reproduction metadata, verified typed related +evidence, and an offline semantic verifier. It also includes the deterministic +bad-agent/good-agent demonstration and the least-privilege AgentTX Proof Verifier +GitHub Action. + +AgentTX isolates Git-visible repository changes; it is not an OS sandbox and +cannot reverse pushes, messages, APIs, database writes, or other external side +effects. Proof integrity is unsigned and recomputable, not producer authentication. diff --git a/package-lock.json b/package-lock.json index bcb428b..19a30f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agenttx", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agenttx", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "bin": { "agenttx": "dist/src/cli.js" diff --git a/package.json b/package.json index ffff9cc..d23261a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "agenttx", - "version": "0.2.0", - "description": "Git-style transactions for AI coding agents. Inspect, commit, or roll back agent changes.", + "version": "0.3.0", + "description": "Proof-carrying transactions for AI coding agents. Verify, commit, or roll back every change.", "type": "module", "bin": { "agenttx": "./dist/src/cli.js" @@ -17,6 +17,7 @@ "files": [ "dist", "scripts/fake-agent.mjs", + "scripts/proof-demo.mjs", "docs", "README.md", "LICENSE", @@ -30,6 +31,8 @@ "test": "tsc -p tsconfig.build.json && vitest run", "test:coverage": "tsc -p tsconfig.build.json && vitest run --coverage", "demo": "node dist/src/cli.js demo", + "demo:proof": "node scripts/proof-demo.mjs", + "gallery:generate": "node scripts/generate-proof-gallery.mjs", "demo:keep": "node dist/src/cli.js demo --keep", "benchmark": "node dist/benchmarks/workspace.js", "benchmark:quick": "node dist/benchmarks/workspace.js 100", diff --git a/scripts/generate-proof-gallery.mjs b/scripts/generate-proof-gallery.mjs new file mode 100644 index 0000000..df71acf --- /dev/null +++ b/scripts/generate-proof-gallery.mjs @@ -0,0 +1,144 @@ +import { execFile } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const source = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cli = join(source, "dist", "src", "cli.js"); +const outputFlag = process.argv.indexOf("--output"); +const site = resolve(outputFlag >= 0 ? process.argv[outputFlag + 1] : "gallery-site"); +const work = await mkdtemp(join(tmpdir(), "agenttx-gallery-")); + +async function repository(name, files) { + const root = join(work, name); + for (const [path, content] of Object.entries(files)) { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); + } + await exec("git", ["-C", root, "init", "-q"]); + await exec("git", ["-C", root, "add", "-A"]); + await exec("git", ["-C", root, "-c", "user.name=AEB Proof Gallery", "-c", "user.email=gallery@aeb.invalid", "commit", "-q", "-m", "gallery fixture"]); + return root; +} + +async function proof(root, destination, command, config) { + await mkdir(join(root, ".agenttx"), { recursive: true }); + await writeFile(join(root, ".agenttx", "proof.json"), `${JSON.stringify(config, null, 2)}\n`); + await exec(process.execPath, [cli, "proof", "--allow-external", "--config", ".agenttx/proof.json", "--output", destination, "--", ...command], { + cwd: root, + timeout: 55_000, + maxBuffer: 16 * 1024 * 1024 + }); +} + +await mkdir(site, { recursive: true }); + +const demoRoot = join(work, "test-weakening-demo"); +await exec(process.execPath, [join(source, "scripts", "proof-demo.mjs"), "--output", demoRoot], { + cwd: source, + timeout: 55_000, + maxBuffer: 16 * 1024 * 1024 +}); +await cp(join(demoRoot, "bad-proof"), join(site, "agent-cheated"), { recursive: true }); + +const resilireplay = process.env.RESILIREPLAY_COMMAND ?? "resilireplay"; +const mcpRoot = await repository("mcp-duplicate-effect", { + "README.md": "# MCP retry policy\n\nEvidence pending.\n" +}); +await proof(mcpRoot, join(site, "mcp-duplicate-effect"), [resilireplay, "mcp", "demo", "--output", ".resilireplay/demo", "--json"], { + validators: [], + relatedEvidence: [{ + producer: "io.github.aliengineering-byte/resilireplay", + version: "0.7.1", + capability: "bounded-mcp-retry-without-duplicate-effect", + path: ".resilireplay/demo/evidence.json", + verify: [resilireplay, "mcp", "verify-evidence", "{evidence}", "--json"], + required: true + }] +}); + +const python = process.env.GALLERY_PYTHON ?? (process.platform === "win32" ? "python.exe" : "python3"); +const phaseprobe = process.env.PHASEPROBE_COMMAND ?? "phaseprobe"; +const scienceRoot = await repository("scientific-transition", { + "README.md": "# Logistic transition policy\n\nEvidence pending.\n", + "scripts/scientific-change.py": `from pathlib import Path +import shutil +import subprocess + +subprocess.run([${JSON.stringify(phaseprobe)}, "scan", "--example", "logistic", "--output-root", ".phaseprobe/gallery", "--json"], check=True) +fixtures = sorted(Path(".phaseprobe/gallery").glob("*/replay.json")) +if len(fixtures) != 1: + raise SystemExit("expected exactly one PhaseProbe replay") +shutil.copyfile(fixtures[0], "phaseprobe-replay.json") +Path("README.md").write_text("# Logistic transition policy\\n\\nThe bounded period-2/period-4 transition is preserved as a replay.\\n", encoding="utf-8") +` +}); +await proof(scienceRoot, join(site, "scientific-transition"), [python, "scripts/scientific-change.py"], { + validators: [], + relatedEvidence: [{ + producer: "aliengineering-byte/phaseprobe", + version: "0.3.0", + capability: "bounded-qualitative-transition-replay", + path: "phaseprobe-replay.json", + verify: [phaseprobe, "replay", "{evidence}", "--json"], + required: true + }] +}); + +const commit = (await exec("git", ["-C", source, "rev-parse", "HEAD"])).stdout.trim(); +const escape = (value) => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +const entries = [ + { + slug: "agent-cheated", + title: "Agent cheated by weakening a test", + problem: "A command replaced a protected failing assertion with an always-green test.", + behavior: "The policy validator detected the weakened test; the claimed success was rejected and the original tree restored.", + component: "AgentTX 0.3.0", + command: "npm run demo:proof", + verdict: "ROLLED_BACK", + limitations: "Deterministic fixture policy evidence; not a general detector for every possible test weakening." + }, + { + slug: "mcp-duplicate-effect", + title: "MCP retry created or threatened a duplicate effect", + problem: "A controlled MCP tool failure exercised a retry boundary where duplicate effects are the central risk.", + behavior: "ResiliReplay reproduced the failure, bounded recovery to one retry, observed zero duplicate effects, and generated a regression.", + component: "AgentTX 0.3.0 + ResiliReplay 0.7.1", + command: "agenttx proof --allow-external -- resilireplay mcp demo --output .resilireplay/demo --json", + verdict: "PASS", + limitations: "Local deterministic MCP fixture evidence; it does not prove an arbitrary remote tool is idempotent." + }, + { + slug: "scientific-transition", + title: "Scientific behavior crossed a qualitative transition", + problem: "A logistic-map parameter crossed a finite-time period-2/period-4 classification boundary.", + behavior: "PhaseProbe found and replayed a bounded bracket; AgentTX bound its independently verified replay evidence.", + component: "AgentTX 0.3.0 + PhaseProbe 0.3.0", + command: "agenttx proof --allow-external -- python scripts/scientific-change.py", + verdict: "PASS", + limitations: "Numerical finite-time classification evidence; not an exact bifurcation point or scientific truth claim." + } +]; + +for (const entry of entries) { + const artifact = JSON.parse(await readFile(join(site, entry.slug, "proof.json"), "utf8")); + if (artifact.proof.transaction.verdict !== entry.verdict) throw new Error(`${entry.slug} verdict mismatch`); + const color = entry.verdict === "PASS" ? "#087f5b" : "#c92a2a"; + const related = artifact.proof.relatedEvidence.length + ? `${artifact.proof.relatedEvidence[0].producer} ${artifact.proof.relatedEvidence[0].producerVersion}` + : "AgentTX policy validator"; + await writeFile(join(site, entry.slug, "proof-card.svg"), `AgentTX Proof ${entry.verdict}${escape(entry.title)}AGENTTX PROOF CARD${entry.verdict}${escape(entry.title)}${escape(related)}sha256:${artifact.integrity.digest}agenttx verify-proof proof.json`, "utf8"); +} + +const cards = entries.map((entry) => ``).join("\n"); +await writeFile(join(site, "index.html"), `AEB Proof Gallery

AEB Proof

Three reproducible proof packs

Generated in CI from real commands. No accounts, analytics, telemetry, or external storage.

${cards}
AgentTX 0.3.0 · source ${commit} · receipts use unsigned recomputable integrity, not producer authentication.
`, "utf8"); +await writeFile(join(site, ".nojekyll"), "", "utf8"); +process.stdout.write(`${JSON.stringify({ status: "PASS", entries: entries.length, sourceCommit: commit, site })}\n`); diff --git a/scripts/proof-demo.mjs b/scripts/proof-demo.mjs new file mode 100644 index 0000000..1ce090f --- /dev/null +++ b/scripts/proof-demo.mjs @@ -0,0 +1,111 @@ +import { execFile } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cli = join(packageRoot, "dist", "src", "cli.js"); +const outputIndex = process.argv.indexOf("--output"); +const requestedOutput = outputIndex >= 0 ? process.argv[outputIndex + 1] : undefined; +const root = requestedOutput ? resolve(requestedOutput) : await mkdtemp(join(tmpdir(), "agenttx-proof-demo-")); +const repository = join(root, "fixture"); +await mkdir(join(repository, "src"), { recursive: true }); +await mkdir(join(repository, "test"), { recursive: true }); +await mkdir(join(repository, "scripts"), { recursive: true }); +await writeFile(join(repository, "package.json"), `${JSON.stringify({ + name: "agenttx-proof-demo", + private: true, + type: "module", + scripts: { test: "node --test" } +}, null, 2)}\n`); +await writeFile(join(repository, "src", "total.js"), "export const total = (left, right) => left - right;\n"); +const protectedTest = `import test from "node:test"; +import assert from "node:assert/strict"; +import { total } from "../src/total.js"; + +test("adds invoice line items", () => { + assert.equal(total(20, 22), 42); // AGENTTX_POLICY_REQUIRED +}); +`; +await writeFile(join(repository, "test", "total.test.js"), protectedTest); +await writeFile(join(repository, "scripts", "bad-agent.mjs"), `import { writeFile } from "node:fs/promises"; +await writeFile("test/total.test.js", "import test from 'node:test';\\nimport assert from 'node:assert/strict';\\ntest('looks green', () => assert.ok(true));\\n"); +`); +await writeFile(join(repository, "scripts", "good-agent.mjs"), `import { writeFile } from "node:fs/promises"; +await writeFile("src/total.js", "export const total = (left, right) => left + right;\\n"); +`); +await writeFile(join(repository, "scripts", "test-policy.mjs"), `import { readFile } from "node:fs/promises"; +const source = await readFile("test/total.test.js", "utf8"); +if (!source.includes("AGENTTX_POLICY_REQUIRED") || !source.includes("assert.equal(total(20, 22), 42)")) { + console.error("policy: protected assertion was weakened or removed"); + process.exit(1); +} +`); +await execFileAsync("git", ["-C", repository, "init", "-q"]); +await execFileAsync("git", ["-C", repository, "add", "-A"]); +await execFileAsync("git", ["-C", repository, "-c", "user.name=AgentTX Demo", "-c", "user.email=demo@agenttx.local", "commit", "-q", "-m", "buggy baseline"]); + +const validators = [ + JSON.stringify([process.execPath, "scripts/test-policy.mjs"]), + JSON.stringify([process.execPath, "--test"]) +]; +async function proof(agent, output) { + const args = [cli, "proof", "--output", output]; + for (const validator of validators) args.push("--validator", validator); + args.push("--", process.execPath, `scripts/${agent}-agent.mjs`); + try { + return { ...(await execFileAsync(process.execPath, args, { cwd: repository, timeout: 30_000 })), exitCode: 0 }; + } catch (error) { + return { + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + exitCode: typeof error.code === "number" ? error.code : 1 + }; + } +} + +const started = Date.now(); +const badProof = join(root, "bad-proof"); +const bad = await proof("bad", badProof); +if (bad.exitCode === 0 || !bad.stdout.includes("ROLLED_BACK")) throw new Error(`Bad agent was not rejected.\n${bad.stdout}\n${bad.stderr}`); +const statusAfterBad = (await execFileAsync("git", ["-C", repository, "status", "--porcelain"])).stdout.trim(); +if (statusAfterBad) throw new Error(`Rollback did not restore the fixture: ${statusAfterBad}`); +await execFileAsync(process.execPath, [cli, "verify-proof", join(badProof, "proof.json")], { cwd: repository }); + +const tamperedProof = join(root, "tampered-proof"); +await cp(badProof, tamperedProof, { recursive: true, errorOnExist: true }); +const tamperedPath = join(tamperedProof, "proof.json"); +const tampered = JSON.parse(await readFile(tamperedPath, "utf8")); +tampered.proof.transaction.reason = "tampered claim"; +await writeFile(tamperedPath, `${JSON.stringify(tampered, null, 2)}\n`); +let tamperRejected = false; +try { + await execFileAsync(process.execPath, [cli, "verify-proof", tamperedPath], { cwd: repository }); +} catch { + tamperRejected = true; +} +if (!tamperRejected) throw new Error("Tampered proof was accepted."); + +const goodProof = join(root, "good-proof"); +const good = await proof("good", goodProof); +if (good.exitCode !== 0 || !good.stdout.includes("PASS")) throw new Error(`Good agent was not accepted.\n${good.stdout}\n${good.stderr}`); +await execFileAsync(process.execPath, [cli, "verify-proof", join(goodProof, "proof.json")], { cwd: repository }); +if ((await readFile(join(repository, "src", "total.js"), "utf8")).includes("left - right")) { + throw new Error("Good fix was not applied."); +} +if (await readFile(join(repository, "test", "total.test.js"), "utf8") !== protectedTest) { + throw new Error("Good agent changed the protected test."); +} + +const summary = { + demo: "agenttx-proof-bad-agent-v1", + elapsedMs: Date.now() - started, + badAgent: { verdict: "ROLLED_BACK", proof: join(badProof, "proof.json") }, + tamperedReceipt: "REJECTED", + goodAgent: { verdict: "PASS", proof: join(goodProof, "proof.json") }, + repository +}; +process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); diff --git a/scripts/public-adoption-snapshot.mjs b/scripts/public-adoption-snapshot.mjs new file mode 100644 index 0000000..fa7193c --- /dev/null +++ b/scripts/public-adoption-snapshot.mjs @@ -0,0 +1,45 @@ +import { writeFile } from "node:fs/promises"; + +const destination = process.argv[2] ?? "gallery-site/adoption.json"; +const headers = { "User-Agent": "agenttx-public-adoption-snapshot" }; +if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + +async function json(url, authorized = false) { + const response = await fetch(url, { headers: authorized ? headers : { "User-Agent": headers["User-Agent"] } }); + if (!response.ok) return null; + return response.json(); +} + +const [agenttx, resilireplay, npmAgenttx, npmResiliReplay, actionReferences, rrRegistry, gmRegistry] = await Promise.all([ + json("https://api.github.com/repos/aliengineering-byte/agenttx", true), + json("https://api.github.com/repos/aliengineering-byte/resilireplay", true), + json("https://api.npmjs.org/downloads/point/last-week/agenttx"), + json("https://api.npmjs.org/downloads/point/last-week/resilireplay"), + json("https://api.github.com/search/code?q=%22aliengineering-byte%2Fagenttx%40%22+path%3A.github%2Fworkflows", true), + json("https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.aliengineering-byte%2Fresilireplay"), + json("https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.aliengineering-byte%2Fgaugemesh") +]); +const registryHas = (value, name) => Boolean(value?.servers?.some((entry) => (entry.server ?? entry).name === name)); +const snapshot = { + schemaVersion: "aeb.public-adoption.v1", + capturedAt: new Date().toISOString(), + primaryMetric: { + name: "verified external proof runs", + value: null, + reason: "No telemetry is collected; only voluntarily public, independently observable runs can be counted." + }, + publicAggregates: { + agenttx: { npmDownloadsLastWeek: npmAgenttx?.downloads ?? null, stars: agenttx?.stargazers_count ?? null, forks: agenttx?.forks_count ?? null, openIssues: agenttx?.open_issues_count ?? null }, + resilireplay: { npmDownloadsLastWeek: npmResiliReplay?.downloads ?? null, stars: resilireplay?.stargazers_count ?? null, forks: resilireplay?.forks_count ?? null, openIssues: resilireplay?.open_issues_count ?? null }, + repositoriesReferencingAgenttxAction: actionReferences?.total_count ?? null, + officialMcpRegistry: { resilireplay: registryHas(rrRegistry, "io.github.aliengineering-byte/resilireplay"), gaugemesh: registryHas(gmRegistry, "io.github.aliengineering-byte/gaugemesh") }, + ghcrPulls: null + }, + limitations: [ + "Downloads, stars, forks, and references are public distribution signals, not unique users or successful proof runs.", + "Bot traffic is not classified as adoption.", + "GHCR does not expose a reliable anonymous aggregate pull count here." + ] +}; +await writeFile(destination, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); +console.log(JSON.stringify(snapshot)); diff --git a/src/cli.ts b/src/cli.ts index c33702f..c9ebaec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,7 +10,7 @@ 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"; +import { redactText, sanitizeCommand } from "./core/redaction.js"; import { runTransaction } from "./core/runner.js"; import { runShim } from "./core/shims.js"; import { listTransactions, resolveTransaction } from "./core/store.js"; @@ -32,6 +32,14 @@ import { renderVerification } from "./reporters/terminal.js"; import { VERSION } from "./version.js"; +import { findRepository } from "./core/git.js"; +import { pathExists } from "./core/fs.js"; +import { loadProofConfig, validateArgv } from "./proof/config.js"; +import { badgeSnippet, initializeGitHub } from "./proof/init.js"; +import { renderProofCard } from "./proof/render.js"; +import { runProof } from "./proof/run.js"; +import type { ProofArtifact, ProofOptions, ProofPrivacy, ProofValidatorConfig } from "./proof/types.js"; +import { verifyProofArtifact, verifyProofFile } from "./proof/verify.js"; const execFileAsync = promisify(execFile); const cliPath = fileURLToPath(import.meta.url); @@ -68,6 +76,11 @@ function help(): string { Usage: agenttx run [--allow-external] [--] + agenttx proof [options] -- + agenttx verify-proof + agenttx render-proof [--output proof.html] + agenttx init --github [--badge owner/repository] + agenttx feedback agenttx status [transaction-id] [--json] agenttx diff [transaction-id] [--stat|--full] agenttx inspect [transaction-id] [--json] @@ -86,6 +99,189 @@ External writes detected by top-level matching or PATH shims are blocked by defa Detection is heuristic; AgentTX V0 is not an OS security sandbox.`; } +interface ParsedProof { + options: ProofOptions; + json: boolean; +} + +function boundedInteger(value: string | undefined, name: string, minimum: number, maximum: number): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(`${name} must be an integer between ${minimum} and ${maximum}.`); + } + return parsed; +} + +function inlineValidator(value: string | undefined, required: boolean, index: number): ProofValidatorConfig { + if (!value) throw new Error("Validator options require a JSON argv array."); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error("Validator must be a JSON argv array, for example '[\"npm\",\"test\"]'."); + } + return { id: `${required ? "required" : "optional"}-${index}`, argv: validateArgv(parsed, "validator"), required }; +} + +async function parseProof(args: string[]): Promise { + const separator = args.indexOf("--"); + if (separator < 0) throw new Error("agenttx proof requires -- before the command."); + const flags = args.slice(0, separator); + const argv = validateArgv(args.slice(separator + 1), "proof command"); + let configPath: string | undefined; + let outputDirectory: string | undefined; + let privacy: ProofPrivacy = "paths"; + let timeoutMs = 120_000; + let maxOutputBytes = 1024 * 1024; + let maxEvidenceBytes = 16 * 1024 * 1024; + let shell = false; + let allowExternal = false; + let commitOnSuccess = true; + let rollbackOnFailure = true; + let dryRun = false; + let json = false; + const inline: ProofValidatorConfig[] = []; + for (let index = 0; index < flags.length; index += 1) { + const flag = flags[index]; + if (flag === "--config") configPath = flags[++index]; + else if (flag === "--output" || flag === "--output-dir") outputDirectory = flags[++index]; + else if (flag === "--privacy") { + const value = flags[++index]; + if (value !== "paths" && value !== "minimal") throw new Error("--privacy must be paths or minimal."); + privacy = value; + } else if (flag === "--timeout-ms") timeoutMs = boundedInteger(flags[++index], flag, 100, 3_600_000); + else if (flag === "--max-output-bytes") maxOutputBytes = boundedInteger(flags[++index], flag, 1024, 64 * 1024 * 1024); + else if (flag === "--max-evidence-bytes") maxEvidenceBytes = boundedInteger(flags[++index], flag, 1024, 256 * 1024 * 1024); + else if (flag === "--validator") inline.push(inlineValidator(flags[++index], true, inline.length + 1)); + else if (flag === "--optional-validator") inline.push(inlineValidator(flags[++index], false, inline.length + 1)); + else if (flag === "--shell") shell = true; + else if (flag === "--allow-external") allowExternal = true; + else if (flag === "--no-commit") commitOnSuccess = false; + else if (flag === "--no-rollback") rollbackOnFailure = false; + else if (flag === "--dry-run") dryRun = true; + else if (flag === "--json") json = true; + else throw new Error(`Unknown proof option: ${flag}`); + } + const repositoryRoot = await findRepository(process.cwd()); + const config = await loadProofConfig(repositoryRoot, configPath); + return { + json, + options: { + command: { command: argv[0] as string, args: argv.slice(1) }, + ...(outputDirectory ? { outputDirectory } : {}), + ...(configPath ? { configPath } : {}), + validators: [...(config.validators ?? []), ...inline], + relatedEvidence: config.relatedEvidence ?? [], + privacy, + timeoutMs, + maxOutputBytes, + maxEvidenceBytes, + shell, + allowExternal, + commitOnSuccess, + rollbackOnFailure, + dryRun + } + }; +} + +async function handleProof(args: string[]): Promise { + const parsed = await parseProof(args); + if (parsed.options.dryRun) { + const plan = { + valid: true, + dryRun: true, + command: sanitizeCommand(parsed.options.command.command, parsed.options.command.args), + validators: parsed.options.validators, + relatedEvidence: parsed.options.relatedEvidence, + bounds: { + timeoutMs: parsed.options.timeoutMs, + maxOutputBytes: parsed.options.maxOutputBytes, + maxEvidenceBytes: parsed.options.maxEvidenceBytes, + maxNesting: 1 + }, + shell: parsed.options.shell, + allowExternal: parsed.options.allowExternal, + commitOnSuccess: parsed.options.commitOnSuccess, + rollbackOnFailure: parsed.options.rollbackOnFailure + }; + print(JSON.stringify(plan, null, parsed.json ? 0 : 2)); + return; + } + const result = await runProof(process.cwd(), cliPath, parsed.options); + if (parsed.json) { + print(JSON.stringify({ + verdict: result.artifact.proof.transaction.verdict, + digest: result.artifact.integrity.digest, + transactionState: result.artifact.proof.transaction.state, + proofJson: result.proofPath, + proofCard: result.cardPath, + reproduction: result.reproductionPath + })); + } else { + print(`${result.artifact.proof.transaction.verdict}: ${result.artifact.proof.transaction.reason}`); + print(`Proof JSON: ${result.proofPath}`); + print(`Proof Card: ${result.cardPath}`); + print(`Digest: ${result.artifact.integrity.digest}`); + print(`Verify: agenttx verify-proof "${result.proofPath}"`); + } + if (result.artifact.proof.transaction.verdict !== "PASS") process.exitCode = 1; +} + +async function handleVerifyProof(args: string[]): Promise { + const path = positional(args)[0]; + if (!path) throw new Error("agenttx verify-proof requires proof.json."); + const verification = await verifyProofFile(path); + if (hasFlag(args, "--json")) print(JSON.stringify(verification)); + else { + print(`Proof verified: ${verification.verdict}`); + print(`Receipt SHA-256: ${verification.digest}`); + print(`Transaction: ${verification.transactionId}`); + print("Proof Card and reproduction record match. Authentication: none."); + } +} + +async function handleRenderProof(args: string[]): Promise { + const source = positional(args)[0]; + if (!source) throw new Error("agenttx render-proof requires proof.json."); + const artifact = JSON.parse(await readFile(resolve(source), "utf8")) as ProofArtifact; + verifyProofArtifact(artifact); + const destination = resolve(flagValue(args, "--output") ?? "proof.html"); + if (await pathExists(destination)) throw new Error(`Refusing to overwrite existing file: ${destination}`); + await writeFile(destination, renderProofCard(artifact), { flag: "wx", mode: 0o600 }); + print(destination); +} + +async function handleInit(args: string[]): Promise { + if (!hasFlag(args, "--github")) throw new Error("agenttx init currently requires --github."); + const repositoryRoot = await findRepository(process.cwd()); + const paths = await initializeGitHub(repositoryRoot); + for (const path of paths) print(`Created ${path}`); + const badge = flagValue(args, "--badge"); + if (badge) { + print("\nOptional workflow badge (not written):"); + print(badgeSnippet(badge)); + } +} + +async function handleFeedback(args: string[]): Promise { + const path = positional(args)[0]; + if (!path) throw new Error("agenttx feedback requires proof.json."); + const verification = await verifyProofFile(path); + const artifact = JSON.parse(await readFile(resolve(path), "utf8")) as ProofArtifact; + const included = { + agenttxVersion: artifact.proof.agenttxVersion, + verdict: verification.verdict, + receiptDigest: verification.digest, + transactionId: verification.transactionId + }; + print("No data was uploaded. This URL includes exactly:"); + print(JSON.stringify(included, null, 2)); + const body = encodeURIComponent(`AgentTX proof feedback\n\n${JSON.stringify(included, null, 2)}`); + print(`\nhttps://github.com/aliengineering-byte/agenttx/issues/new?title=Proof%20feedback&body=${body}`); + print("The browser was not opened."); +} + function parseRun(args: string[]): { allowExternal: boolean; command: CommandSpec } { const remaining = [...args]; let allowExternal = false; @@ -289,6 +485,14 @@ async function main(): Promise { await handleVerifyEvidence(args); return; } + if (command === "verify-proof") { + await handleVerifyProof(args); + return; + } + if (command === "render-proof") { + await handleRenderProof(args); + return; + } const recovered = await recoverInterruptedTransactions(); if (recovered.length && !hasFlag(args, "--json")) { for (const item of recovered) { @@ -297,6 +501,7 @@ async function main(): Promise { } switch (command) { case "run": await handleRun(args); break; + case "proof": await handleProof(args); break; case "status": await handleStatus(args); break; case "diff": await handleDiff(args); break; case "inspect": await handleInspect(args); break; @@ -309,6 +514,8 @@ async function main(): Promise { case "report": await handleReport(args); break; case "doctor": await handleDoctor(args); break; case "demo": await handleDemo(args); break; + case "init": await handleInit(args); break; + case "feedback": await handleFeedback(args); break; case "--version": case "-v": print(VERSION); break; case "help": diff --git a/src/core/runner.ts b/src/core/runner.ts index 9d32f68..7c93956 100644 --- a/src/core/runner.ts +++ b/src/core/runner.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { spawn, type ChildProcess } from "node:child_process"; import { extname, join } from "node:path"; import { detectSideEffect, shouldBlockFinding } from "../detectors/side-effects.js"; @@ -14,6 +15,30 @@ export interface RunResult { metadata: TransactionMetadata; exitCode: number; signal: NodeJS.Signals | null; + execution: RunExecution; +} + +export interface RunOptions { + captureOutput?: boolean; + maxOutputBytes?: number; + shell?: boolean; + timeoutMs?: number; +} + +export interface RunExecution { + startedAt: string; + completedAt: string; + durationMs: number; + terminationReason: "exit" | "signal" | "timeout" | "output-limit" | "spawn-error" | "policy-block"; + shell: boolean; + output: { + stdoutBytes: number; + stderrBytes: number; + stdoutSha256: string; + stderrSha256: string; + truncated: boolean; + preview: string[]; + }; } function waitForChild(child: ChildProcess): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { @@ -33,7 +58,8 @@ function signalExitCode(signal: NodeJS.Signals | null): number { export async function runTransaction( initialMetadata: TransactionMetadata, cliPath: string, - executionCommand: CommandSpec + executionCommand: CommandSpec, + options: RunOptions = {} ): Promise { let metadata = initialMetadata; const ledger = new EventLedger(metadata.transactionDirectory); @@ -51,6 +77,26 @@ export async function runTransaction( let exitCode = 0; let signal: NodeJS.Signals | null = null; let interrupted = false; + let terminationReason: RunExecution["terminationReason"] = "exit"; + const captureOutput = options.captureOutput ?? false; + const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024; + if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1024) { + throw new Error("maxOutputBytes must be an integer of at least 1024 bytes."); + } + if ( + options.timeoutMs !== undefined && + (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100) + ) { + throw new Error("timeoutMs must be an integer of at least 100 milliseconds."); + } + const stdoutHash = createHash("sha256"); + const stderrHash = createHash("sha256"); + let stdoutBytes = 0; + let stderrBytes = 0; + let acceptedBytes = 0; + let outputTruncated = false; + let previewText = ""; + const previewLimit = 4096; if (topLevelFinding && shouldBlockFinding(topLevelFinding, metadata.allowExternal)) { const blocked = { ...topLevelFinding, blocked: true }; @@ -61,6 +107,7 @@ export async function runTransaction( `Re-run with --allow-external only if you explicitly accept the external side effect.\n` ); exitCode = 77; + terminationReason = "policy-block"; } else { if (topLevelFinding) { await ledger.append("side_effect.allowed", { @@ -71,6 +118,9 @@ export async function runTransaction( const shimDirectory = await createCommandShims(metadata, cliPath); const workingDirectory = join(metadata.worktree, metadata.invocationDirectory); const extension = extname(executionCommand.command).toLowerCase(); + const useShell = options.shell ?? ( + process.platform === "win32" && [".cmd", ".bat"].includes(extension) + ); const child = spawn(executionCommand.command, executionCommand.args, { cwd: workingDirectory, env: { @@ -81,8 +131,8 @@ export async function runTransaction( AGENTTX_ORIGINAL_WORKSPACE: metadata.repositoryRoot, AGENTTX_EXTERNAL_POLICY: metadata.allowExternal ? "allow" : "block" }, - stdio: "inherit", - shell: process.platform === "win32" && [".cmd", ".bat"].includes(extension), + stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit", + shell: useShell, windowsHide: false }); metadata = { ...metadata, childPid: child.pid }; @@ -91,6 +141,7 @@ export async function runTransaction( const onSignal = (received: NodeJS.Signals): void => { interrupted = true; signal = received; + terminationReason = "signal"; void ledger.append("process.signal", { signal: received }); if (child.exitCode === null && child.signalCode === null) { try { @@ -100,6 +151,41 @@ export async function runTransaction( } } }; + const acceptOutput = (stream: "stdout" | "stderr", chunk: Buffer): void => { + if (outputTruncated) return; + const remaining = maxOutputBytes - acceptedBytes; + const accepted = remaining > 0 ? chunk.subarray(0, remaining) : Buffer.alloc(0); + if (stream === "stdout") { + stdoutBytes += accepted.length; + stdoutHash.update(accepted); + } else { + stderrBytes += accepted.length; + stderrHash.update(accepted); + } + acceptedBytes += accepted.length; + if (previewText.length < previewLimit && accepted.length) { + previewText += accepted.toString("utf8").slice(0, previewLimit - previewText.length); + } + if (accepted.length < chunk.length || acceptedBytes >= maxOutputBytes) { + outputTruncated = true; + terminationReason = "output-limit"; + child.kill("SIGTERM"); + } + }; + if (captureOutput) { + child.stdout?.on("data", (chunk: Buffer) => acceptOutput("stdout", chunk)); + child.stderr?.on("data", (chunk: Buffer) => acceptOutput("stderr", chunk)); + } + let timeout: NodeJS.Timeout | undefined; + if (options.timeoutMs !== undefined) { + timeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + terminationReason = "timeout"; + child.kill("SIGTERM"); + } + }, options.timeoutMs); + timeout.unref(); + } const signalHandlers = new Map void>(); for (const handled of ["SIGINT", "SIGTERM", "SIGHUP"] as NodeJS.Signals[]) { const handler = (): void => onSignal(handled); @@ -114,22 +200,26 @@ export async function runTransaction( const result = await waitForChild(child); exitCode = result.code ?? signalExitCode(result.signal); signal = result.signal; + if (terminationReason === "exit" && result.signal) terminationReason = "signal"; } catch (error) { exitCode = 1; + terminationReason = "spawn-error"; await ledger.append("process.failed", { error: redactText((error as Error).message) }); } finally { + if (timeout) clearTimeout(timeout); for (const [handled, handler] of signalHandlers) process.off(handled, handler); } } const durationMs = Date.now() - started; + const completedAt = new Date().toISOString(); await ledger.append("process.exited", { exitCode, signal, durationMs }); try { await finalizeTransaction(metadata); metadata = await transitionTransaction(metadata, "REVIEW", { exitCode, durationMs, - completedAt: new Date().toISOString(), + completedAt, interrupted: interrupted || signal !== null, childPid: undefined }); @@ -138,12 +228,31 @@ export async function runTransaction( metadata = await transitionTransaction(metadata, "FAILED", { exitCode, durationMs, - completedAt: new Date().toISOString(), + completedAt, interrupted: interrupted || signal !== null, childPid: undefined, failure: redactText((error as Error).message) }); await ledger.append("transaction.failed", { error: redactText((error as Error).message) }); } - return { metadata, exitCode, signal }; + return { + metadata, + exitCode, + signal, + execution: { + startedAt: new Date(started).toISOString(), + completedAt, + durationMs, + terminationReason, + shell: options.shell ?? false, + output: { + stdoutBytes, + stderrBytes, + stdoutSha256: stdoutHash.digest("hex"), + stderrSha256: stderrHash.digest("hex"), + truncated: outputTruncated, + preview: redactText(previewText).split(/\r?\n/).filter(Boolean).slice(0, 20) + } + } + }; } diff --git a/src/index.ts b/src/index.ts index 10bf4f4..14180ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,3 +32,14 @@ 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"; +export type { + ProofArtifact, + ProofConfig, + ProofOptions, + ProofReceipt, + ProofRelatedEvidence, + ProofVerification +} from "./proof/types.js"; +export { renderProofCard, renderReproduction } from "./proof/render.js"; +export { runProof } from "./proof/run.js"; +export { verifyProofArtifact, verifyProofFile } from "./proof/verify.js"; diff --git a/src/proof/config.ts b/src/proof/config.ts new file mode 100644 index 0000000..d8472d7 --- /dev/null +++ b/src/proof/config.ts @@ -0,0 +1,131 @@ +import { readFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; +import { pathExists } from "../core/fs.js"; +import { assertSafeRelativePath } from "../core/fs.js"; +import type { + ProofConfig, + ProofRelatedEvidenceConfig, + ProofValidatorConfig +} from "./types.js"; + +const MAX_VALIDATORS = 32; +const MAX_RELATED_EVIDENCE = 16; +const MAX_ARGV = 128; +const MAX_ARGUMENT_BYTES = 16 * 1024; + +function record(value: unknown, name: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${name} must be an object.`); + } + return value as Record; +} + +function exactKeys(value: Record, allowed: readonly string[], name: string): void { + const unexpected = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unexpected.length) throw new Error(`${name} has unsupported fields: ${unexpected.join(", ")}.`); +} + +function identifier(value: unknown, name: string): string { + if (typeof value !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(value)) { + throw new Error(`${name} must be a 1-64 character identifier.`); + } + return value; +} + +export function validateArgv(value: unknown, name: string): string[] { + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ARGV) { + throw new Error(`${name} must contain 1-${MAX_ARGV} arguments.`); + } + if (value.some((item) => typeof item !== "string" || item.includes("\0"))) { + throw new Error(`${name} must contain only NUL-free strings.`); + } + const argv = value as string[]; + if (Buffer.byteLength(argv.join("\0")) > MAX_ARGUMENT_BYTES) { + throw new Error(`${name} exceeds ${MAX_ARGUMENT_BYTES} bytes.`); + } + return [...argv]; +} + +function validator(value: unknown, index: number): ProofValidatorConfig { + const item = record(value, `validators[${index}]`); + exactKeys(item, ["id", "argv", "required", "timeoutMs", "shell"], `validators[${index}]`); + if (typeof item.required !== "boolean") throw new Error(`validators[${index}].required must be boolean.`); + if (item.shell !== undefined && typeof item.shell !== "boolean") { + throw new Error(`validators[${index}].shell must be boolean.`); + } + if ( + item.timeoutMs !== undefined && + (!Number.isSafeInteger(item.timeoutMs) || (item.timeoutMs as number) < 100 || (item.timeoutMs as number) > 3_600_000) + ) { + throw new Error(`validators[${index}].timeoutMs must be between 100 and 3600000.`); + } + return { + id: identifier(item.id, `validators[${index}].id`), + argv: validateArgv(item.argv, `validators[${index}].argv`), + required: item.required, + ...(item.timeoutMs === undefined ? {} : { timeoutMs: item.timeoutMs as number }), + ...(item.shell === undefined ? {} : { shell: item.shell as boolean }) + }; +} + +function relatedEvidence(value: unknown, index: number): ProofRelatedEvidenceConfig { + const item = record(value, `relatedEvidence[${index}]`); + exactKeys(item, ["producer", "version", "capability", "path", "verify", "required"], `relatedEvidence[${index}]`); + for (const field of ["producer", "version", "capability", "path"] as const) { + if (typeof item[field] !== "string" || !(item[field] as string).trim()) { + throw new Error(`relatedEvidence[${index}].${field} must be a non-empty string.`); + } + } + if (isAbsolute(item.path as string)) { + throw new Error(`relatedEvidence[${index}].path must be relative to the transaction workspace.`); + } + assertSafeRelativePath(item.path as string); + if (item.required !== undefined && typeof item.required !== "boolean") { + throw new Error(`relatedEvidence[${index}].required must be boolean.`); + } + return { + producer: item.producer as string, + version: item.version as string, + capability: item.capability as string, + path: item.path as string, + verify: validateArgv(item.verify, `relatedEvidence[${index}].verify`), + ...(item.required === undefined ? {} : { required: item.required as boolean }) + }; +} + +export function parseProofConfig(value: unknown): ProofConfig { + const config = record(value, "proof configuration"); + exactKeys(config, ["validators", "relatedEvidence"], "proof configuration"); + if (config.validators !== undefined && !Array.isArray(config.validators)) { + throw new Error("validators must be an array."); + } + if (config.relatedEvidence !== undefined && !Array.isArray(config.relatedEvidence)) { + throw new Error("relatedEvidence must be an array."); + } + const validators = (config.validators ?? []) as unknown[]; + const related = (config.relatedEvidence ?? []) as unknown[]; + if (validators.length > MAX_VALIDATORS) throw new Error(`At most ${MAX_VALIDATORS} validators are allowed.`); + if (related.length > MAX_RELATED_EVIDENCE) { + throw new Error(`At most ${MAX_RELATED_EVIDENCE} related evidence artifacts are allowed.`); + } + const parsedValidators = validators.map(validator); + if (new Set(parsedValidators.map((item) => item.id)).size !== parsedValidators.length) { + throw new Error("Validator IDs must be unique."); + } + return { + validators: parsedValidators, + relatedEvidence: related.map(relatedEvidence) + }; +} + +export async function loadProofConfig(repositoryRoot: string, requestedPath?: string): Promise { + const path = resolve(repositoryRoot, requestedPath ?? ".agenttx/proof.json"); + if (!requestedPath && !(await pathExists(path))) return { validators: [], relatedEvidence: [] }; + let value: unknown; + try { + value = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + throw new Error(`Cannot read proof configuration ${path}: ${(error as Error).message}`); + } + return parseProofConfig(value); +} diff --git a/src/proof/init.ts b/src/proof/init.ts new file mode 100644 index 0000000..c0d6535 --- /dev/null +++ b/src/proof/init.ts @@ -0,0 +1,70 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { pathExists } from "../core/fs.js"; + +const CONFIG = `{ + "validators": [ + { + "id": "tests", + "argv": ["npm", "test"], + "required": true, + "shell": true + } + ], + "relatedEvidence": [] +} +`; + +const WORKFLOW = `name: AgentTX Proof + +on: + workflow_dispatch: + inputs: + proof_json: + description: Relative path to proof.json + required: true + default: proof/proof.json + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - id: proof + uses: aliengineering-byte/agenttx@v0.3.0 + with: + proof-json: \${{ inputs.proof_json }} + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: agenttx-proof + path: | + \${{ steps.proof.outputs.proof-json-path }} + \${{ steps.proof.outputs.proof-card-path }} +`; + +export async function initializeGitHub(repositoryRoot: string): Promise { + const files = [ + { path: resolve(repositoryRoot, ".agenttx", "proof.json"), content: CONFIG }, + { path: resolve(repositoryRoot, ".github", "workflows", "agenttx-proof.yml"), content: WORKFLOW } + ]; + const collisions = []; + for (const item of files) if (await pathExists(item.path)) collisions.push(item.path); + if (collisions.length) { + throw new Error(`Refusing to overwrite existing files:\n${collisions.map((path) => ` ${path}`).join("\n")}`); + } + for (const item of files) { + await mkdir(dirname(item.path), { recursive: true }); + await writeFile(item.path, item.content, { flag: "wx", mode: 0o600 }); + } + return files.map((item) => item.path); +} + +export function badgeSnippet(repository: string): string { + const safe = repository.replace(/^https:\/\/github\.com\//, "").replace(/\.git$/, ""); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(safe)) throw new Error("Expected a GitHub owner/repository name."); + const workflow = "agenttx-proof.yml"; + return `[![AgentTX Proof](https://github.com/${safe}/actions/workflows/${workflow}/badge.svg)](https://github.com/${safe}/actions/workflows/${workflow})`; +} diff --git a/src/proof/process.ts b/src/proof/process.ts new file mode 100644 index 0000000..416cd1a --- /dev/null +++ b/src/proof/process.ts @@ -0,0 +1,57 @@ +import { spawn } from "node:child_process"; + +export interface GateResult { + exitCode: number; + durationMs: number; + terminationReason: "exit" | "signal" | "timeout" | "output-limit" | "spawn-error"; +} + +export async function runGate( + argv: readonly string[], + options: { cwd: string; timeoutMs: number; maxOutputBytes: number; shell: boolean } +): Promise { + if (!argv[0]) throw new Error("Gate command is empty."); + const started = Date.now(); + let bytes = 0; + let terminationReason: GateResult["terminationReason"] = "exit"; + return new Promise((resolve) => { + const child = spawn(argv[0] as string, argv.slice(1), { + cwd: options.cwd, + env: process.env, + shell: options.shell, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + let settled = false; + let timer: NodeJS.Timeout | undefined; + const finish = (exitCode: number): void => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve({ exitCode, durationMs: Date.now() - started, terminationReason }); + }; + const consume = (chunk: Buffer): void => { + bytes += chunk.length; + if (bytes > options.maxOutputBytes && terminationReason === "exit") { + terminationReason = "output-limit"; + child.kill("SIGTERM"); + } + }; + child.stdout?.on("data", consume); + child.stderr?.on("data", consume); + child.once("error", () => { + terminationReason = "spawn-error"; + finish(1); + }); + child.once("close", (code, signal) => { + if (terminationReason === "exit" && signal) terminationReason = "signal"; + finish(code ?? 1); + }); + timer = setTimeout(() => { + if (settled) return; + terminationReason = "timeout"; + child.kill("SIGTERM"); + }, options.timeoutMs); + timer.unref(); + }); +} diff --git a/src/proof/render.ts b/src/proof/render.ts new file mode 100644 index 0000000..e9c4f7f --- /dev/null +++ b/src/proof/render.ts @@ -0,0 +1,108 @@ +import type { ProofArtifact, ProofReceipt } from "./types.js"; + +function html(value: unknown): string { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function markdownCode(value: string): string { + return value.replaceAll("`", "\\`"); +} + +function commandLine(receipt: ProofReceipt): string { + return ["agenttx", "proof", "--", ...receipt.reproduction.argv] + .map((arg) => JSON.stringify(arg)) + .join(" "); +} + +export function renderReproduction(receipt: ProofReceipt): string { + return `# Reproduce AgentTX proof ${receipt.transaction.id} + +Run from the repository root at base commit \`${markdownCode(receipt.repository.baseCommit)}\`. + +\`\`\`text +${commandLine(receipt)} +\`\`\` + +Exact argv boundaries: + +\`\`\`json +${JSON.stringify(receipt.reproduction.argv, null, 2)} +\`\`\` + +Verify the copied proof offline: + +\`\`\`text +agenttx verify-proof proof.json +\`\`\` + +${receipt.reproduction.note} + +Limitations: +${receipt.limitations.map((item) => `- ${item}`).join("\n")} +`; +} + +export function renderProofCard(artifact: ProofArtifact): string { + const { proof, integrity } = artifact; + const required = proof.validators.filter((item) => item.required); + const tests = required.length === 0 + ? "No required validators declared" + : required.every((item) => item.status === "passed") + ? `${required.length} required validator${required.length === 1 ? "" : "s"} passed` + : "Required validation failed"; + const rollback = proof.transaction.rollbackCompleted + ? "Rollback completed" + : proof.transaction.state === "REVIEW" + ? "Rollback available" + : "Rollback not available"; + const statusClass = proof.transaction.verdict === "PASS" ? "pass" : "reject"; + const files = proof.changes.files.length + ? `
    ${proof.changes.files.map((item) => `
  • ${html(item.kind)} ${html(item.path ?? "path withheld")}
  • `).join("")}
` + : `

${proof.changes.filesChanged === 0 ? "No Git-visible files changed." : "Changed paths withheld by privacy mode."}

`; + const validators = proof.validators.length + ? `
    ${proof.validators.map((item) => `
  • ${html(item.status.toUpperCase())} ${html(item.id)}${item.required ? " · required" : " · optional"}
  • `).join("")}
` + : "

No validators declared.

"; + const related = proof.relatedEvidence.length + ? `
    ${proof.relatedEvidence.map((item) => `
  • ${html(item.verificationStatus.toUpperCase())} ${html(item.producer)} ${html(item.producerVersion)} · ${html(item.capability)}
  • `).join("")}
` + : "

No related evidence attached.

"; + return ` + + + + + +AgentTX Proof · ${html(proof.transaction.verdict)} + + + +
+
+
AgentTX proof card
+

${html(proof.transaction.verdict)}

+

${html(proof.transaction.reason)}

+
+
${proof.changes.filesChanged} file${proof.changes.filesChanged === 1 ? "" : "s"}${proof.changes.additions} additions · ${proof.changes.deletions} deletions
+
${html(tests)}Claims ${proof.claims.derivedVerdict ? "derived from recorded outcomes" : "not verified"}
+
${html(rollback)}Terminal state: ${html(proof.transaction.state)}
+
+

Evidence digest
sha256:${html(integrity.digest)}

+

Verify locally
${html(proof.verificationCommand)}

+
+

What changed

${files}
+

Validation

${validators}
+

Related evidence

${related}
+

Execution

${html(JSON.stringify([proof.execution.command.command, ...proof.execution.command.args]))}

Exit ${proof.execution.exitCode} · ${html(proof.execution.terminationReason)} · ${proof.execution.durationMs} ms · shell ${proof.execution.shell ? "explicitly enabled" : "disabled"} · external effects ${proof.execution.externalSideEffectsAuthorized ? "explicitly authorized but not reversible" : "not authorized"}

+

Limitations

    ${proof.limitations.map((item) => `
  • ${html(item)}
  • `).join("")}
+
+ + + +`; +} diff --git a/src/proof/run.ts b/src/proof/run.ts new file mode 100644 index 0000000..34ccec3 --- /dev/null +++ b/src/proof/run.ts @@ -0,0 +1,361 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { canonicalSha256 } from "../core/evidence.js"; +import { assertContained, assertSafeRelativePath, pathExists, toPosixPath } from "../core/fs.js"; +import { runGit } from "../core/git.js"; +import { inspectTransaction } from "../core/inspection.js"; +import { sanitizeCommand } from "../core/redaction.js"; +import { runTransaction } from "../core/runner.js"; +import type { VerificationCheck } from "../core/types.js"; +import { commitTransaction, createTransaction, rollbackTransaction } from "../core/workspace.js"; +import { VERSION } from "../version.js"; +import { runGate } from "./process.js"; +import { renderProofCard, renderReproduction } from "./render.js"; +import { + PROOF_CANONICALIZATION, + PROOF_SCHEMA_VERSION, + type ProofArtifact, + type ProofOptions, + type ProofReceipt, + type ProofRelatedEvidence, + type ProofVerification +} from "./types.js"; +import { verifyProofFile } from "./verify.js"; + +interface BufferedEvidence { + receipt: ProofRelatedEvidence; + content: Buffer; +} + +export interface ProofRunResult { + artifact: ProofArtifact; + outputDirectory: string; + proofPath: string; + cardPath: string; + reproductionPath: string; + verification: ProofVerification; +} + +async function reserveOutputDirectory(path: string): Promise { + if (await pathExists(path)) throw new Error(`Refusing to overwrite existing proof output: ${path}`); + await mkdir(dirname(path), { recursive: true }); + await mkdir(path); +} + +async function repositorySource(repositoryRoot: string): Promise { + const result = await runGit(repositoryRoot, ["remote", "get-url", "origin"], { allowFailure: true }); + const value = result.stdout.trim(); + if (!value) return null; + return value.replace(/^(https?:\/\/)[^/@:]+:[^/@]+@/i, "$1[REDACTED]:[REDACTED]@"); +} + +async function runValidators( + worktree: string, + validators: ProofOptions["validators"], + canRun: boolean, + maxOutputBytes: number, + defaultTimeoutMs: number +): Promise> { + const results: Array = []; + for (const validator of validators) { + const [command, ...args] = validator.argv; + if (!command || !canRun) { + results.push({ + id: validator.id, + command: command ?? "", + args, + source: "AgentTX proof configuration", + status: "skipped", + required: validator.required + }); + continue; + } + const result = await runGate(validator.argv, { + cwd: worktree, + timeoutMs: validator.timeoutMs ?? defaultTimeoutMs, + maxOutputBytes, + shell: validator.shell ?? false + }); + const safe = sanitizeCommand(command, args); + results.push({ + id: validator.id, + command: safe.command, + args: safe.args, + source: "AgentTX proof configuration", + status: result.exitCode === 0 && result.terminationReason === "exit" ? "passed" : "failed", + exitCode: result.exitCode, + durationMs: result.durationMs, + required: validator.required + }); + } + return results; +} + +async function collectRelatedEvidence( + worktree: string, + related: ProofOptions["relatedEvidence"], + maxEvidenceBytes: number, + maxOutputBytes: number, + timeoutMs: number +): Promise { + const results: BufferedEvidence[] = []; + let total = 0; + for (let index = 0; index < related.length; index += 1) { + const item = related[index]; + if (!item) continue; + assertSafeRelativePath(item.path); + const source = resolve(worktree, item.path); + assertContained(worktree, source); + const required = item.required ?? true; + let content = Buffer.alloc(0); + let status: ProofRelatedEvidence["verificationStatus"] = "missing"; + if (await pathExists(source)) { + content = await readFile(source); + total += content.length; + if (total > maxEvidenceBytes) { + content = Buffer.alloc(0); + status = "failed"; + } else { + const actualArgv = item.verify.includes("{evidence}") + ? item.verify.map((arg) => arg === "{evidence}" ? source : arg) + : [...item.verify, source]; + const verification = await runGate(actualArgv, { + cwd: worktree, + timeoutMs, + maxOutputBytes, + shell: false + }); + status = verification.exitCode === 0 && verification.terminationReason === "exit" ? "passed" : "failed"; + } + } + const safeVerify = sanitizeCommand(item.verify[0] ?? "", item.verify.slice(1)); + const safeName = basename(item.path).replace(/[^a-z0-9._-]/gi, "_") || "evidence.bin"; + results.push({ + content, + receipt: { + producer: item.producer, + producerVersion: item.version, + capability: item.capability, + artifactPath: toPosixPath(join("related", `${String(index + 1).padStart(2, "0")}-${safeName}`)), + artifactSha256: createHash("sha256").update(content).digest("hex"), + verificationCommand: safeVerify, + verificationStatus: status, + required + } + }); + } + return results; +} + +function reasonFor( + commandSucceeded: boolean, + validatorsPassed: boolean, + evidencePassed: boolean, + commitError: string | null, + rollbackError: string | null +): string { + if (rollbackError) return "Required gates failed and rollback could not be completed; inspect the isolated transaction."; + if (commitError) return "All required gates passed, but applying the accepted change failed; the isolated change was rolled back."; + if (!commandSucceeded) return "The command did not complete successfully; its isolated changes were rejected."; + if (!validatorsPassed) return "A required validator failed; the claimed success was rejected."; + if (!evidencePassed) return "Required related evidence failed verification; the claimed success was rejected."; + return "The command and every required gate passed; the change was accepted."; +} + +export async function runProof(cwd: string, cliPath: string, options: ProofOptions): Promise { + if (process.env.AGENTTX_TRANSACTION_ID) { + throw new Error("Nested AgentTX proof execution is refused (maximum nesting is 1)."); + } + if (options.dryRun) throw new Error("runProof cannot execute a dry run."); + if (options.shell && process.platform === "win32" && options.command.command !== "cmd.exe") { + throw new Error("On Windows, explicit shell mode requires cmd.exe as the command so argv boundaries remain visible."); + } + const requestedOutput = options.outputDirectory ? resolve(cwd, options.outputDirectory) : undefined; + if (requestedOutput && await pathExists(requestedOutput)) { + throw new Error(`Refusing to overwrite existing proof output: ${requestedOutput}`); + } + const metadata = await createTransaction(cwd, options.command, { + allowExternal: options.allowExternal, + agent: "proof-command" + }); + const outputDirectory = requestedOutput ?? join(metadata.transactionDirectory, "proof"); + try { + await reserveOutputDirectory(outputDirectory); + } catch (error) { + await rollbackTransaction(metadata); + throw error; + } + const before = JSON.parse(await readFile(join(metadata.transactionDirectory, "before.json"), "utf8")) as unknown; + const source = await repositorySource(metadata.repositoryRoot); + const run = await runTransaction(metadata, cliPath, options.command, { + captureOutput: true, + maxOutputBytes: options.maxOutputBytes, + timeoutMs: options.timeoutMs, + shell: options.shell + }); + const inspection = await inspectTransaction(run.metadata); + const commandSucceeded = run.exitCode === 0 && run.execution.terminationReason === "exit"; + const validators = await runValidators( + run.metadata.worktree, + options.validators, + commandSucceeded, + options.maxOutputBytes, + options.timeoutMs + ); + const validatorsPassed = validators.filter((item) => item.required).every((item) => item.status === "passed"); + const related = await collectRelatedEvidence( + run.metadata.worktree, + options.relatedEvidence, + options.maxEvidenceBytes, + options.maxOutputBytes, + options.timeoutMs + ); + const evidencePassed = related.filter((item) => item.receipt.required) + .every((item) => item.receipt.verificationStatus === "passed"); + const accepted = commandSucceeded && validatorsPassed && evidencePassed; + let finalMetadata = run.metadata; + let commitApplied = false; + let rollbackCompleted = false; + let unrelatedWorkspacePreserved: boolean | null = null; + let commitError: string | null = null; + let rollbackError: string | null = null; + if (accepted && options.commitOnSuccess) { + try { + finalMetadata = (await commitTransaction(run.metadata)).metadata; + commitApplied = true; + } catch (error) { + commitError = (error as Error).message; + if (options.rollbackOnFailure) { + try { + const rolledBack = await rollbackTransaction(run.metadata); + finalMetadata = rolledBack.metadata; + rollbackCompleted = true; + unrelatedWorkspacePreserved = rolledBack.originalWorkspaceStatusUnchanged; + } catch (rollbackFailure) { + rollbackError = (rollbackFailure as Error).message; + } + } + } + } else if (!accepted && options.rollbackOnFailure) { + try { + const rolledBack = await rollbackTransaction(run.metadata); + finalMetadata = rolledBack.metadata; + rollbackCompleted = true; + unrelatedWorkspacePreserved = rolledBack.originalWorkspaceStatusUnchanged; + } catch (error) { + rollbackError = (error as Error).message; + } + } + const verdict = rollbackCompleted ? "ROLLED_BACK" : accepted && !commitError ? "PASS" : "REJECTED"; + const safeCommand = sanitizeCommand(options.command.command, options.command.args); + const pathsIncluded = options.privacy === "paths"; + const completedAt = finalMetadata.completedAt ?? new Date().toISOString(); + const receipt: ProofReceipt = { + schemaVersion: PROOF_SCHEMA_VERSION, + agenttxVersion: VERSION, + producer: { + repository: "https://github.com/aliengineering-byte/agenttx", + capability: "proof-carrying-repository-transaction" + }, + repository: { + source, + baseCommit: metadata.baseHead, + beforeStateSha256: canonicalSha256(before), + afterStateSha256: canonicalSha256({ baseCommit: metadata.baseHead, diff: inspection.diff }) + }, + transaction: { + id: metadata.transactionId, + state: finalMetadata.status, + accepted, + verdict, + reason: reasonFor(commandSucceeded, validatorsPassed, evidencePassed, commitError, rollbackError), + commitApplied, + rollbackCompleted, + unrelatedWorkspacePreserved + }, + execution: { + command: safeCommand, + exitCode: run.exitCode, + signal: run.signal, + terminationReason: run.execution.terminationReason, + startedAt: run.execution.startedAt, + completedAt: run.execution.completedAt, + durationMs: run.execution.durationMs, + shell: options.shell, + externalSideEffectsAuthorized: options.allowExternal, + output: options.privacy === "minimal" + ? { ...run.execution.output, preview: [] } + : run.execution.output + }, + bounds: { + timeoutMs: options.timeoutMs, + maxOutputBytes: options.maxOutputBytes, + maxEvidenceBytes: options.maxEvidenceBytes, + maxNesting: 1 + }, + changes: { + filesChanged: inspection.diff.filesChanged, + additions: inspection.diff.additions, + deletions: inspection.diff.deletions, + binaryFiles: inspection.diff.binaryFiles, + pathsIncluded, + files: inspection.diff.files.map((file) => ({ + ...(pathsIncluded ? { path: file.path, ...(file.oldPath ? { oldPath: file.oldPath } : {}) } : {}), + kind: file.kind + })) + }, + validators, + relatedEvidence: related.map((item) => item.receipt), + claims: { + commandSucceeded, + requiredValidatorsPassed: validatorsPassed, + requiredEvidenceVerified: evidencePassed, + derivedVerdict: true + }, + timestamps: { startedAt: run.execution.startedAt, completedAt }, + privacy: { + mode: options.privacy, + environmentCaptured: false, + promptsCaptured: false, + secrets: "redacted" + }, + reproduction: { + argv: [safeCommand.command, ...safeCommand.args], + workingDirectory: "repository-root", + note: "Arguments containing recognized credentials are redacted and must be supplied again locally. External side effects are outside the rollback guarantee." + }, + verificationCommand: "agenttx verify-proof proof.json", + limitations: [ + "AgentTX isolates Git-visible repository changes; it is not an operating-system sandbox.", + "Remote pushes, messages, API calls, database writes, and other external side effects cannot be rolled back by this receipt.", + "Ignored files and unrelated external state are outside the recorded workspace-state digest.", + "The receipt provides unsigned, recomputable integrity, not authentication of the producer." + ] + }; + const artifact: ProofArtifact = { + proof: receipt, + integrity: { + algorithm: "sha256", + canonicalization: PROOF_CANONICALIZATION, + scope: "proof", + authentication: "none", + digest: canonicalSha256(receipt) + } + }; + const json = `${JSON.stringify(artifact, null, 2)}\n`; + const card = renderProofCard(artifact); + const reproduction = renderReproduction(receipt); + const proofPath = join(outputDirectory, "proof.json"); + const cardPath = join(outputDirectory, "proof.html"); + const reproductionPath = join(outputDirectory, "reproduce.md"); + await mkdir(join(outputDirectory, "related"), { recursive: true }); + await Promise.all([ + writeFile(proofPath, json, { flag: "wx", mode: 0o600 }), + writeFile(cardPath, card, { flag: "wx", mode: 0o600 }), + writeFile(reproductionPath, reproduction, { flag: "wx", mode: 0o600 }), + ...related.map((item) => writeFile(resolve(outputDirectory, item.receipt.artifactPath), item.content, { flag: "wx", mode: 0o600 })) + ]); + const verification = await verifyProofFile(proofPath); + return { artifact, outputDirectory, proofPath, cardPath, reproductionPath, verification }; +} diff --git a/src/proof/types.ts b/src/proof/types.ts new file mode 100644 index 0000000..606574c --- /dev/null +++ b/src/proof/types.ts @@ -0,0 +1,178 @@ +import type { + ChangeKind, + CommandSpec, + TransactionState, + VerificationCheck +} from "../core/types.js"; + +export const PROOF_SCHEMA_VERSION = "agenttx.proof.v1" as const; +export const PROOF_CANONICALIZATION = "agenttx-canonical-json-v1" as const; + +export type ProofVerdict = "PASS" | "REJECTED" | "ROLLED_BACK"; +export type ProofPrivacy = "paths" | "minimal"; +export type ProofTerminationReason = + | "exit" + | "signal" + | "timeout" + | "output-limit" + | "spawn-error" + | "policy-block"; + +export interface ProofOutputMetadata { + stdoutBytes: number; + stderrBytes: number; + stdoutSha256: string; + stderrSha256: string; + truncated: boolean; + preview: string[]; +} + +export interface ProofExecution { + command: CommandSpec; + exitCode: number; + signal: NodeJS.Signals | null; + terminationReason: ProofTerminationReason; + startedAt: string; + completedAt: string; + durationMs: number; + shell: boolean; + externalSideEffectsAuthorized: boolean; + output: ProofOutputMetadata; +} + +export interface ProofValidatorConfig { + id: string; + argv: string[]; + required: boolean; + timeoutMs?: number; + shell?: boolean; +} + +export interface ProofRelatedEvidenceConfig { + producer: string; + version: string; + capability: string; + path: string; + verify: string[]; + required?: boolean; +} + +export interface ProofConfig { + validators?: ProofValidatorConfig[]; + relatedEvidence?: ProofRelatedEvidenceConfig[]; +} + +export interface ProofOptions { + command: CommandSpec; + outputDirectory?: string; + configPath?: string; + validators: ProofValidatorConfig[]; + relatedEvidence: ProofRelatedEvidenceConfig[]; + privacy: ProofPrivacy; + timeoutMs: number; + maxOutputBytes: number; + maxEvidenceBytes: number; + shell: boolean; + allowExternal: boolean; + commitOnSuccess: boolean; + rollbackOnFailure: boolean; + dryRun: boolean; +} + +export interface ProofRelatedEvidence { + producer: string; + producerVersion: string; + capability: string; + artifactPath: string; + artifactSha256: string; + verificationCommand: CommandSpec; + verificationStatus: "passed" | "failed" | "missing"; + required: boolean; +} + +export interface ProofReceipt { + schemaVersion: typeof PROOF_SCHEMA_VERSION; + agenttxVersion: string; + producer: { + repository: "https://github.com/aliengineering-byte/agenttx"; + capability: "proof-carrying-repository-transaction"; + }; + repository: { + source: string | null; + baseCommit: string; + beforeStateSha256: string; + afterStateSha256: string; + }; + transaction: { + id: string; + state: TransactionState; + accepted: boolean; + verdict: ProofVerdict; + reason: string; + commitApplied: boolean; + rollbackCompleted: boolean; + unrelatedWorkspacePreserved: boolean | null; + }; + execution: ProofExecution; + bounds: { + timeoutMs: number; + maxOutputBytes: number; + maxEvidenceBytes: number; + maxNesting: 1; + }; + changes: { + filesChanged: number; + additions: number; + deletions: number; + binaryFiles: number; + pathsIncluded: boolean; + files: Array<{ path?: string; oldPath?: string; kind: ChangeKind }>; + }; + validators: Array; + relatedEvidence: ProofRelatedEvidence[]; + claims: { + commandSucceeded: boolean; + requiredValidatorsPassed: boolean; + requiredEvidenceVerified: boolean; + derivedVerdict: true; + }; + timestamps: { + startedAt: string; + completedAt: string; + }; + privacy: { + mode: ProofPrivacy; + environmentCaptured: false; + promptsCaptured: false; + secrets: "redacted"; + }; + reproduction: { + argv: string[]; + workingDirectory: "repository-root"; + note: string; + }; + verificationCommand: "agenttx verify-proof proof.json"; + limitations: string[]; +} + +export interface ProofArtifact { + proof: ProofReceipt; + integrity: { + algorithm: "sha256"; + canonicalization: typeof PROOF_CANONICALIZATION; + scope: "proof"; + authentication: "none"; + digest: string; + }; +} + +export interface ProofVerification { + valid: true; + verdict: ProofVerdict; + transactionId: string; + digest: string; + relatedEvidenceVerified: number; + proofCardVerified: boolean; + reproductionVerified: boolean; + authentication: "none"; +} diff --git a/src/proof/verify.ts b/src/proof/verify.ts new file mode 100644 index 0000000..c05b300 --- /dev/null +++ b/src/proof/verify.ts @@ -0,0 +1,158 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { canonicalSha256 } from "../core/evidence.js"; +import { assertContained, assertSafeRelativePath } from "../core/fs.js"; +import { renderProofCard, renderReproduction } from "./render.js"; +import { + PROOF_CANONICALIZATION, + PROOF_SCHEMA_VERSION, + type ProofArtifact, + type ProofReceipt, + type ProofVerification +} from "./types.js"; + +const SHA256 = /^[a-f0-9]{64}$/; + +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 string(value: unknown, name: string): string { + if (typeof value !== "string" || !value) throw new Error(`Invalid ${name}.`); + return value; +} + +function digest(value: unknown, name: string): string { + const result = string(value, name); + if (!SHA256.test(result)) throw new Error(`Invalid ${name}.`); + return result; +} + +function integer(value: unknown, name: string, minimum = 0): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum) throw new Error(`Invalid ${name}.`); + return value as number; +} + +function verifySemantics(proof: ProofReceipt): void { + if (proof.schemaVersion !== PROOF_SCHEMA_VERSION) throw new Error("Unsupported proof schema version."); + const commandSucceeded = + proof.execution.exitCode === 0 && proof.execution.terminationReason === "exit"; + const validatorsPassed = proof.validators + .filter((item) => item.required) + .every((item) => item.status === "passed"); + const evidencePassed = proof.relatedEvidence + .filter((item) => item.required) + .every((item) => item.verificationStatus === "passed"); + if (proof.claims.commandSucceeded !== commandSucceeded) throw new Error("Command success claim is not derived."); + if (proof.claims.requiredValidatorsPassed !== validatorsPassed) { + throw new Error("Validator success claim is not derived."); + } + if (proof.claims.requiredEvidenceVerified !== evidencePassed) { + throw new Error("Related-evidence claim is not derived."); + } + const accepted = commandSucceeded && validatorsPassed && evidencePassed; + if (proof.transaction.accepted !== accepted) throw new Error("Transaction acceptance is not derived."); + if (proof.transaction.commitApplied !== (proof.transaction.state === "COMMITTED")) { + throw new Error("Commit outcome does not match terminal state."); + } + if (proof.transaction.rollbackCompleted !== (proof.transaction.state === "ROLLED_BACK")) { + throw new Error("Rollback outcome does not match terminal state."); + } + if (proof.transaction.verdict === "PASS") { + if (!accepted || !["COMMITTED", "REVIEW"].includes(proof.transaction.state)) { + throw new Error("PASS verdict is inconsistent with recorded outcomes."); + } + } else if (proof.transaction.verdict === "ROLLED_BACK") { + if (!proof.transaction.rollbackCompleted) throw new Error("ROLLED_BACK verdict lacks a completed rollback."); + } else if (proof.transaction.verdict === "REJECTED") { + if (accepted || proof.transaction.rollbackCompleted) { + throw new Error("REJECTED verdict is inconsistent with recorded outcomes."); + } + } else { + throw new Error("Invalid proof verdict."); + } + integer(proof.bounds.timeoutMs, "timeout bound", 100); + integer(proof.bounds.maxOutputBytes, "output bound", 1024); + integer(proof.bounds.maxEvidenceBytes, "evidence bound", 1024); + if (proof.bounds.maxNesting !== 1) throw new Error("Invalid nesting bound."); + if (proof.execution.output.stdoutBytes + proof.execution.output.stderrBytes > proof.bounds.maxOutputBytes) { + throw new Error("Recorded output exceeds its declared bound."); + } + if (proof.changes.files.length > proof.changes.filesChanged) { + throw new Error("Changed-path list exceeds the recorded file count."); + } + for (const file of proof.changes.files) { + if (file.path) assertSafeRelativePath(file.path); + if (file.oldPath) assertSafeRelativePath(file.oldPath); + } + if (proof.privacy.environmentCaptured !== false || proof.privacy.promptsCaptured !== false) { + throw new Error("Proof violates the no-environment/no-prompt schema guarantee."); + } + if (typeof proof.execution.externalSideEffectsAuthorized !== "boolean") { + throw new Error("Proof does not declare external-side-effect authorization."); + } +} + +export function verifyProofArtifact(value: unknown): ProofVerification { + const outer = record(value, "proof artifact"); + const proof = record(outer.proof, "proof receipt") as unknown as ProofReceipt; + const integrity = record(outer.integrity, "proof integrity"); + if (integrity.algorithm !== "sha256") throw new Error("Unsupported proof digest algorithm."); + if (integrity.canonicalization !== PROOF_CANONICALIZATION) throw new Error("Unsupported canonicalization."); + if (integrity.scope !== "proof" || integrity.authentication !== "none") { + throw new Error("Invalid proof integrity declaration."); + } + const expectedDigest = digest(integrity.digest, "proof digest"); + if (canonicalSha256(proof) !== expectedDigest) throw new Error("Proof receipt digest mismatch."); + verifySemantics(proof); + return { + valid: true, + verdict: proof.transaction.verdict, + transactionId: string(proof.transaction.id, "transaction ID"), + digest: expectedDigest, + relatedEvidenceVerified: proof.relatedEvidence.filter((item) => item.verificationStatus === "passed").length, + proofCardVerified: false, + reproductionVerified: false, + authentication: "none" + }; +} + +export async function verifyProofFile(path: string): Promise { + const proofPath = resolve(path); + const bytes = await readFile(proofPath); + if (bytes.length > 16 * 1024 * 1024) throw new Error("Proof JSON exceeds the verifier input bound."); + let artifact: ProofArtifact; + try { + artifact = JSON.parse(bytes.toString("utf8")) as ProofArtifact; + } catch (error) { + throw new Error(`Cannot parse proof JSON: ${(error as Error).message}`); + } + const verification = verifyProofArtifact(artifact); + const root = dirname(proofPath); + let relatedBytes = 0; + for (const related of artifact.proof.relatedEvidence) { + assertSafeRelativePath(related.artifactPath); + const artifactPath = resolve(root, related.artifactPath); + assertContained(root, artifactPath); + const content = await readFile(artifactPath); + relatedBytes += content.length; + if (relatedBytes > artifact.proof.bounds.maxEvidenceBytes) { + throw new Error("Related evidence exceeds the declared evidence bound."); + } + const actual = createHash("sha256").update(content).digest("hex"); + if (actual !== digest(related.artifactSha256, "related evidence digest")) { + throw new Error(`Related evidence digest mismatch: ${related.artifactPath}`); + } + } + const expectedHtml = renderProofCard(artifact); + const expectedReproduction = renderReproduction(artifact.proof); + const [actualHtml, actualReproduction] = await Promise.all([ + readFile(resolve(root, "proof.html"), "utf8"), + readFile(resolve(root, "reproduce.md"), "utf8") + ]); + if (actualHtml !== expectedHtml) throw new Error("Proof Card does not match proof.json."); + if (actualReproduction !== expectedReproduction) throw new Error("Reproduction record does not match proof.json."); + return { ...verification, proofCardVerified: true, reproductionVerified: true }; +} diff --git a/src/version.ts b/src/version.ts index edbab61..387b59e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.2.0"; +export const VERSION = "0.3.0"; diff --git a/tests/integration/proof.test.ts b/tests/integration/proof.test.ts new file mode 100644 index 0000000..8e7c580 --- /dev/null +++ b/tests/integration/proof.test.ts @@ -0,0 +1,184 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { promisify } from "node:util"; +import { beforeEach, describe, expect, it } from "vitest"; +import { parseProofConfig } from "../../src/proof/config.js"; +import { runProof } from "../../src/proof/run.js"; +import type { ProofOptions } from "../../src/proof/types.js"; +import { verifyProofFile } from "../../src/proof/verify.js"; +import { builtCli, createRepository, isolatedHome, text } from "../helpers.js"; + +const execFileAsync = promisify(execFile); + +function options(source: string, overrides: Partial = {}): ProofOptions { + return { + command: { command: process.execPath, args: ["-e", source] }, + validators: [], + relatedEvidence: [], + privacy: "paths", + timeoutMs: 10_000, + maxOutputBytes: 64 * 1024, + maxEvidenceBytes: 1024 * 1024, + shell: false, + allowExternal: false, + commitOnSuccess: true, + rollbackOnFailure: true, + dryRun: false, + ...overrides + }; +} + +describe("AgentTX proof mode", () => { + beforeEach(async () => isolatedHome()); + + it("commits a passing change and verifies deterministic proof artifacts", async () => { + const repository = await createRepository({ "value.txt": "before\n" }); + const result = await runProof( + repository, + builtCli, + options("require('node:fs').writeFileSync('value.txt', 'after\\n')", { + validators: [{ + id: "value-policy", + argv: [process.execPath, "-e", "process.exit(require('node:fs').readFileSync('value.txt','utf8') === 'after\\n' ? 0 : 1)"], + required: true + }] + }) + ); + expect(result.artifact.proof.transaction.verdict).toBe("PASS"); + expect(result.artifact.proof.transaction.state).toBe("COMMITTED"); + expect(await text(join(repository, "value.txt"))).toBe("after\n"); + await expect(verifyProofFile(result.proofPath)).resolves.toMatchObject({ valid: true, proofCardVerified: true }); + }); + + it("rejects test weakening, rolls back, and fails closed after receipt tampering", async () => { + const original = "const policy = 'MUST_KEEP_ASSERTION';\n"; + const repository = await createRepository({ "test/policy.test.js": original }); + const result = await runProof( + repository, + builtCli, + options("require('node:fs').writeFileSync('test/policy.test.js', '// skipped\\n')", { + validators: [{ + id: "no-test-weakening", + argv: [process.execPath, "-e", "process.exit(require('node:fs').readFileSync('test/policy.test.js','utf8').includes('MUST_KEEP_ASSERTION') ? 0 : 1)"], + required: true + }] + }) + ); + expect(result.artifact.proof.transaction.verdict).toBe("ROLLED_BACK"); + expect(result.artifact.proof.transaction.unrelatedWorkspacePreserved).toBe(true); + expect(await text(join(repository, "test/policy.test.js"))).toBe(original); + const artifact = JSON.parse(await readFile(result.proofPath, "utf8")) as { + proof: { transaction: { reason: string } }; + }; + artifact.proof.transaction.reason = "tampered"; + await writeFile(result.proofPath, `${JSON.stringify(artifact)}\n`); + await expect(verifyProofFile(result.proofPath)).rejects.toThrow(/digest mismatch/i); + }); + + it("binds verified related evidence and rejects copied-evidence tampering", async () => { + const repository = await createRepository({ "value.txt": "before\n" }); + const result = await runProof( + repository, + builtCli, + options("require('node:fs').writeFileSync('evidence.json', '{\"ok\":true}\\n')", { + relatedEvidence: [{ + producer: "example/reliability-engine", + version: "1.0.0", + capability: "verify-replay", + path: "evidence.json", + verify: [process.execPath, "-e", "const p=process.argv[1];process.exit(JSON.parse(require('node:fs').readFileSync(p,'utf8')).ok ? 0 : 1)", "{evidence}"], + required: true + }] + }) + ); + expect(result.artifact.proof.claims.requiredEvidenceVerified).toBe(true); + const relatedPath = join(result.outputDirectory, result.artifact.proof.relatedEvidence[0]?.artifactPath ?? "missing"); + await writeFile(relatedPath, "{}\n"); + await expect(verifyProofFile(result.proofPath)).rejects.toThrow(/related evidence digest mismatch/i); + }); + + it("rejects unsafe related paths and existing output directories", async () => { + expect(() => parseProofConfig({ + relatedEvidence: [{ producer: "x", version: "1", capability: "x", path: "../secret", verify: ["verify"] }] + })).toThrow(/escapes workspace/i); + const repository = await createRepository(); + await expect(runProof(repository, builtCli, options("", { outputDirectory: repository }))) + .rejects.toThrow(/refusing to overwrite/i); + }); + + it("escapes HTML-sensitive paths and output", async () => { + const repository = await createRepository(); + const result = await runProof( + repository, + builtCli, + options("require('node:fs').writeFileSync('.txt','');console.log('')") + ); + const card = await text(result.cardPath); + expect(card).toContain("<proof>.txt"); + expect(card).not.toContain(""); + expect(card).not.toContain(""); + }); + + it("redacts credentials from command and output metadata", async () => { + const repository = await createRepository(); + const secret = ["ghp", "abcdefghijklmnopqrstuvwxyz123456"].join("_"); + const result = await runProof( + repository, + builtCli, + options(`console.log('${secret}');require('node:fs').writeFileSync('safe.txt','ok')`, { + command: { command: process.execPath, args: ["-e", `console.log('${secret}');require('node:fs').writeFileSync('safe.txt','ok')`] } + }) + ); + const serialized = JSON.stringify(result.artifact); + expect(serialized).not.toContain(secret); + expect(serialized).toContain("[REDACTED]"); + }); + + it("rolls back when required related evidence exceeds its bound and still emits a proof", async () => { + const repository = await createRepository({ "value.txt": "before\n" }); + const result = await runProof( + repository, + builtCli, + options("require('node:fs').writeFileSync('large.json','x'.repeat(2048));require('node:fs').writeFileSync('value.txt','after\\n')", { + maxEvidenceBytes: 1024, + relatedEvidence: [{ + producer: "bounded-producer", + version: "1.0.0", + capability: "bounded-evidence", + path: "large.json", + verify: [process.execPath, "-e", "process.exit(0)"], + required: true + }] + }) + ); + expect(result.artifact.proof.transaction.verdict).toBe("ROLLED_BACK"); + expect(result.artifact.proof.relatedEvidence[0]?.verificationStatus).toBe("failed"); + expect(await text(join(repository, "value.txt"))).toBe("before\n"); + await expect(verifyProofFile(result.proofPath)).resolves.toMatchObject({ valid: true, verdict: "ROLLED_BACK" }); + }); + + it("runs the Marketplace Action entry point and emits verified outputs", async () => { + const repository = await createRepository(); + const output = join(repository, "proof-pack"); + await runProof(repository, builtCli, options("", { outputDirectory: output })); + const actionFiles = await mkdtemp(join(tmpdir(), "agenttx-action-")); + const githubOutput = join(actionFiles, "output.txt"); + const githubSummary = join(actionFiles, "summary.md"); + await execFileAsync(process.execPath, ["action/index.js"], { + cwd: process.cwd(), + env: { + ...process.env, + AGENTTX_CLI_PATH: builtCli, + GITHUB_WORKSPACE: repository, + GITHUB_OUTPUT: githubOutput, + GITHUB_STEP_SUMMARY: githubSummary, + "INPUT_PROOF-JSON": "proof-pack/proof.json", + "INPUT_RENDER-CARD": "true" + } + }); + expect(await text(githubOutput)).toContain("verdict=PASS"); + expect(await text(githubSummary)).toContain("## AgentTX proof: PASS"); + }); +});