Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "commitproof",
"displayName": "CommitProof: AI Audit Trail",
"version": "1.3.1",
"description": "An audit trail for AI-written code. /ai-history answers 'how much of this repo did Claude help write?' in seconds from git history, no setup. /receipt-init then gives every commit a tamper-evident receipt (AI-assisted or not, model, Claude Code version, file hashes): hash-chained, signed, verified offline. /receipt-report turns the chain into change-control evidence for SOC 2, HIPAA or PCI audits.",
"version": "1.4.0",
"description": "An audit trail for AI-written code. /ai-history answers 'how much of this repo did Claude help write?' in seconds from git history, no setup. /receipt-init then gives every commit a tamper-evident receipt (AI-assisted or not, model, Claude Code version, file hashes): hash-chained, signed with your own SSH key (or a shared key), verified offline. /receipt-report turns the chain into change-control evidence for SOC 2, HIPAA or PCI audits.",
"author": {
"name": "Rehan Masood",
"email": "rmasood524@gmail.com",
Expand Down
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## 1.4.0 — 2026-10-20

**SSH signing: receipts that say who signed, verifiable with no secret.**
HMAC receipts carried two caveats: anyone holding the shared `.titan/key` can
forge any receipt, and a clone without the key can't check signatures.

- **New: `/receipt-init --ssh [KEY]`.** Each receipt is signed by the
committer's own SSH key (`ssh-keygen -Y sign`, namespace
`commitproof-receipt`, so it can't be replayed as a git commit signature or
the other way round). The key defaults to git's SSH signing key, else
`~/.ssh/id_ed25519`. The public key and git email are added to the
committed `.titan/allowed_signers`; the key path stays in this clone's local
git config. A probe signature must succeed without a terminal before
anything is written, so a passphrase-only key is refused at init with how to
fix it, not discovered on the first commit. Teammates run it in their own
clones; in a repo that already has `allowed_signers`, plain init uses SSH.
- Receipts record `signing_version: ssh-sig-v1` and a `signer` block
(principal, key fingerprint) **inside the hash**: swapping the signer breaks
the chain, and verification requires the key that verified to be the one
the receipt names.
- **Verify, report and badge check receipt by receipt**, so a chain can move
from HMAC to SSH. `verify` names who signed; `--public-only` checks
everything that needs no secret and says HMAC was skipped;
`--allowed-signers FILE` trusts a pinned list instead of the repo's. The
report gains a **Signed by** table. The badge says "chain verified" only when
every signature was checked, "partly verified" when some couldn't be.
- **CI action:** SSH signatures are checked with no secret (`verify
--public-only` when no `key` is given), and a new `allowed_signers` input
pins who may sign, outside the repo under audit.
- **No signature downgrade.** Once a chain has an SSH receipt, any later HMAC
receipt is invalid, checked or not (order from the chain's own prev links),
and HMAC signing is refused in a repo with `allowed_signers`. Otherwise
someone with push access but no registered key could append an HMAC-labelled
receipt and pass `--public-only`, which skips HMAC. With a pinned
`--allowed-signers`, HMAC receipts that can't be checked fail instead of
passing. Found by an automated security review before release.
- **Never silent:** if the Claude Code hook can't sign (key gone, needs a
passphrase), the user gets a message saying the commit has no receipt and
how to fix it. No unsigned receipt is ever written.
- An autocrlf checkout of `allowed_signers` verifies the same as the original.
HMAC chains are unchanged.

## 1.3.1 — 2026-10-20

No behavior change. The directory held 1.3.0 under its credential-forward
Expand Down
4 changes: 4 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ commitproof collects nothing.
commit trailers) and are written into your repo, under your control.
- The signing key is generated locally and gitignored; this plugin never
transmits it anywhere.
- With SSH signing (`/receipt-init --ssh`), your SSH public key and git email
are written to `.titan/allowed_signers` in your repo, for you to commit. Your
private key is used only by your local `ssh-keygen`, by path; the plugin
never reads, copies or sends it.

Questions: https://github.com/Rmasood1122/commitproof/issues
66 changes: 46 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,19 @@ what receipts close.
Open Claude Code **inside the repository you want receipts for**:

```
/receipt-init # one-time: signing key (gitignored), vendored verifier, git post-commit hook
/receipt-init --ssh # one-time: sign with your SSH key, vendor the verifier, install a git post-commit hook
git commit -m "…" # every commit from now on is receipted, human or AI
/verify-chain # walks the chain offline; exit 0 means intact
/receipt-report --badge docs/ai-audit-badge.svg
```

**Sign with your own SSH key (recommended).** `--ssh` uses git's SSH signing
key if you have one, else `~/.ssh/id_ed25519`, and adds your public key to
`.titan/allowed_signers`. Each receipt then names who signed it, and anyone
can verify with no secret: a fresh clone, a reviewer, CI. Teammates run
`/receipt-init --ssh` in their own clones. Without `--ssh`, init creates a
shared HMAC key (`.titan/key`, gitignored) that every verifier needs.

`/receipt-report` writes the evidence document: verification result first,
then AI-assisted share by author, month and recorder, and the models and
Claude Code versions recorded. **[See a sample report](docs/sample-report.md).**
Expand All @@ -75,8 +82,10 @@ Each receipt is one small JSON file in `.titan/attestations/`:
"models": ["claude-opus-5-5"], "claude_code_version": "2.1.299",
"session_id": "7f3a…", "transcript": { "lines": 412, "content_sha256": "2ef9732e…" } },
"prev_receipt_hash": "b9f069af…",
"signing_version": "ssh-sig-v1",
"signer": { "principal": "dev@example.com", "key": "SHA256:9+gzyPF4a6eV…" },
"receipt_hash": "c41d0e7b…",
"signature": "80265ea9…"
"signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lH…"
}
```

Expand All @@ -101,9 +110,9 @@ was altered, and **unsigned** when it comes from `/ai-history`.
| Command | What it does |
|---|---|
| `/ai-history` | Share of commits and lines added with a Claude trailer, by author and month, from git history. No setup, writes nothing, unsigned. `--since`, `--json`, `--badge`. |
| `/receipt-init` | Generates the signing key (gitignored; init refuses if git tracks it), vendors the verifier into `.titan/tools/`, installs a git `post-commit` hook. `--no-git-hook` to skip the hook. |
| `/receipt-init` | `--ssh [KEY]`: registers your SSH key in `.titan/allowed_signers` and signs with it (no secret). Default: generates a shared HMAC key (gitignored; init refuses if git tracks it). Either way it vendors the verifier into `.titan/tools/` and installs a git `post-commit` hook. `--no-git-hook` to skip the hook. |
| `/receipt` | Writes a signed, chained receipt for HEAD by hand. One per commit; re-runs are no-ops. |
| `/verify-chain` | Walks the whole chain offline: structure and every signature. Any tamper exits 1 and names the receipt. `--structure-only` checks without the key and says signatures were not checked. Ends with an `ANCHOR` line: how many receipts are committed **and pushed**. |
| `/verify-chain` | Walks the whole chain offline: structure and every signature, naming who signed each SSH receipt. Any tamper exits 1 and names the receipt. `--public-only` checks everything that needs no secret (structure + SSH) and says HMAC signatures were skipped; `--structure-only` skips signatures; `--allowed-signers FILE` trusts that list instead of the repo's. Ends with an `ANCHOR` line: how many receipts are committed **and pushed**. |
| `/receipt-report` | Markdown evidence: verification result, AI-assisted share by recorder / author / month, models and Claude Code versions recorded, and the limits footer. `--out`, `--badge`. |

## Two hooks, full coverage
Expand All @@ -116,7 +125,7 @@ was altered, and **unsigned** when it comes from `/ai-history`.
Neither hook can break a commit: an uninitialized repo is a silent no-op
(plus a one-time notice naming `/receipt-init`; `COMMITPROOF_QUIET=1`
turns it off), and the git hook always exits 0. Commit `.titan/attestations/`
and `.titan/tools/`, never `.titan/key`. Git hooks aren't cloned, so each
and `.titan/tools/` (and `.titan/allowed_signers`), never `.titan/key`. Git hooks aren't cloned, so each
teammate runs `python3 .titan/tools/receipt.py install-hook` once per clone
(`python` or `py` on Windows).

Expand All @@ -131,15 +140,18 @@ and a report built as change-control evidence. The two work side by side.
## Verify in CI

```yaml
- uses: Rmasood1122/commitproof@v1.3.1
- uses: Rmasood1122/commitproof@v1.4.0
with:
key: ${{ secrets.TITAN_KEY }} # omit for structure-only verification
report: docs/ai-change-receipts.md # optional
report: docs/ai-change-receipts.md # optional
allowed_signers: ${{ vars.COMMITPROOF_SIGNERS }} # optional: who may sign, pinned outside the repo
key: ${{ secrets.TITAN_KEY }} # only for HMAC-signed receipts
```

Without the key the action verifies chain structure and content hashes and
says plainly that signatures were not checked. The key is written only for
the step and removed after. The action verifies with **its own pinned
SSH-signed receipts are verified with no secret. Without `key`, HMAC-signed
receipts are reported as not checked, never as passed; with it, the key is
written only for the step and removed after. Set `allowed_signers` to a list
you control (a repository or organization variable) so the repo under audit
doesn't get to decide who may sign. The action verifies with **its own pinned
verifier**, never the `.titan/tools/receipt.py` in the repo under audit: the
party who could alter the receipts also controls that copy. Pin a ref you
trust. `allow_repo_verifier: true` runs the repo's copy for the rare
Expand All @@ -149,14 +161,19 @@ compatibility case and warns that the result is self-reported.

**Proves:** the recorded content (file hashes, diff hash, author, provenance
claims) is unchanged since signing, and the receipts form one unbroken ordered
chain, under a key your team controls.
chain. With SSH signing it also proves **which key signed each receipt**: the
one `.titan/allowed_signers` lists for the named signer, checkable by anyone.

<details>
<summary><strong>Does not prove</strong>: read before writing a compliance claim</summary>

- **Who held the key.** HMAC is a shared secret; anyone with `.titan/key` can
produce valid signatures. Asymmetric signing and external timestamping live
in the upstream [titan-gate](https://github.com/Rmasood1122/titan-gate) engine.
- **Who held the key, for HMAC receipts.** HMAC is a shared secret; anyone
with `.titan/key` can produce valid signatures. Use `--ssh` to close this.
- **Who controls an SSH key.** An SSH receipt identifies the key, and the
person only as far as key custody goes. The key-to-person mapping is only as
trustworthy as `allowed_signers`' git history (adding a key is a visible
commit), or pin the list in CI. External timestamping lives in the upstream
[titan-gate](https://github.com/Rmasood1122/titan-gate) engine.
- **That anyone reviewed the change**, or that it was good.
- **That provenance is true.** `ai_assisted`, model, version, session and
transcript hash are recorded claims: trailers from the commit message and
Expand Down Expand Up @@ -188,17 +205,26 @@ lines, because a receipt system that overclaims is worse than none.
transcript named in the hook payload to record model names, the Claude
Code version, its line count and sha256. Transcript content is never copied
into the receipt. Writes one JSON file under `.titan/attestations/`.
- **`/receipt-init`:** creates `.titan/key` and adds it to `.gitignore`,
copies `receipt.py`, `badge.py`, `canonical.py` and `chain_state.py` into
`.titan/tools/`, and installs or appends to `.git/hooks/post-commit`.
Nothing else in your repo changes.
- **`/receipt-init`:** creates `.titan/key` and adds it to `.gitignore`
(or, with `--ssh`, adds your SSH public key and git email to
`.titan/allowed_signers` and records the key's path in this clone's local
git config as `commitproof.signingkey`), copies `receipt.py`,
`badge.py`, `canonical.py`, `chain_state.py` and `signing.py` into
`.titan/tools/`, and
installs or appends to `.git/hooks/post-commit`. Nothing else in your repo
changes.
- **SSH signing:** runs your local `ssh-keygen` (`-Y sign` for each receipt,
`-Y verify` when checking). Your private key is only ever passed to
`ssh-keygen` by path, and never read, copied or sent anywhere.
- **`/ai-history`, `/verify-chain`, `/receipt-report`:** read-only, apart
from the `--out` or `--badge` file you name.
- **Network:** none. No telemetry, no package installs, no service.

## Requirements and privacy

Claude Code, git, and Python 3.10+ on PATH as `python3`, `python` or `py`.
Claude Code, git, and Python 3.10+ on PATH as `python3`, `python` or `py`;
for SSH signing, OpenSSH 8.2+ `ssh-keygen` (ships with Git for Windows,
macOS and Linux).
Stdlib only: no packages, no network, no service, nothing phones home
([PRIVACY.md](PRIVACY.md)). CI runs the full suite, every tamper path
included, on Ubuntu and Windows.
Expand Down
27 changes: 22 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
name: "CommitProof — verify receipt chain"
description: "Verify a repository's .titan/attestations/ receipt chain offline with THIS action's pinned verifier (not the repo's). Without a key: chain structure + content hashes (and says signatures were not checked). With the key secret: full signature verification."
description: "Verify a repository's .titan/attestations/ receipt chain offline with THIS action's pinned verifier (not the repo's). SSH-signed receipts are checked with no secret; HMAC receipts need the key secret, and without it the action says they were not checked."
branding:
icon: shield
color: gray-dark
inputs:
key:
description: "Hex signing key (pass a secret). Omit for structure-only verification."
description: "Hex HMAC key (pass a secret). Only HMAC-signed receipts need it; omit it and SSH signatures are still checked."
required: false
default: ""
report:
description: "Path to write the markdown compliance report to (optional)."
required: false
default: ""
allowed_signers:
description: >-
Trusted allowed_signers content (a secret or variable you control). When
set, SSH signatures are checked against these keys instead of the audited
repo's .titan/allowed_signers: the repo under audit should not decide who
may sign. Omit it to trust the repo's file (changes to it are visible in
git history).
required: false
default: ""
allow_repo_verifier:
description: >-
DANGER — weakens the guarantee. When "true", run the verifier vendored in
Expand All @@ -33,6 +42,7 @@ runs:
TITAN_KEY: ${{ inputs.key }}
TITAN_REPORT: ${{ inputs.report }}
TITAN_ALLOW_REPO_VERIFIER: ${{ inputs.allow_repo_verifier }}
TITAN_ALLOWED_SIGNERS: ${{ inputs.allowed_signers }}
run: |
set -euo pipefail
# Trust model: the verifier MUST be this action's own pinned copy, not
Expand All @@ -50,16 +60,23 @@ runs:
echo "::error title=CommitProof::verifier not found at $TOOL" >&2
exit 1
fi
# Trusted signers, when given, replace the audited repo's own list.
AS=""
if [ -n "$TITAN_ALLOWED_SIGNERS" ]; then
AS="${RUNNER_TEMP:-/tmp}/commitproof_allowed_signers"
printf '%s\n' "$TITAN_ALLOWED_SIGNERS" > "$AS"
fi
rc=0
if [ -n "$TITAN_KEY" ]; then
mkdir -p .titan && printf '%s\n' "$TITAN_KEY" > .titan/key && chmod 600 .titan/key
python3 "$TOOL" verify || rc=$?
python3 "$TOOL" verify ${AS:+--allowed-signers "$AS"} || rc=$?
else
python3 "$TOOL" verify --structure-only || rc=$?
# no secret: SSH signatures are still checked; HMAC ones are reported unchecked
python3 "$TOOL" verify --public-only ${AS:+--allowed-signers "$AS"} || rc=$?
fi
# The report is wanted MOST when verification fails — it records BROKEN.
if [ -n "$TITAN_REPORT" ]; then
python3 "$TOOL" report --out "$TITAN_REPORT" || true
python3 "$TOOL" report --out "$TITAN_REPORT" ${AS:+--allowed-signers "$AS"} || true
fi
if [ -n "$TITAN_KEY" ]; then rm -f .titan/key; fi
exit $rc
34 changes: 31 additions & 3 deletions commands/receipt-init.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Start here — one-time setup that turns on receipts for this repo so every commit (AI-assisted or not) gets a tamper-evident record. Creates a gitignored signing key, a vendored verifier, and a git post-commit hook
argument-hint: "[--no-git-hook] [--force]"
description: Start here — one-time setup that turns on receipts for this repo so every commit (AI-assisted or not) gets a tamper-evident record. --ssh signs with your own SSH key (no shared secret, anyone can verify); default is a gitignored shared HMAC key. Also vendors the verifier and installs a git post-commit hook
argument-hint: "[--ssh [KEY]] [--no-git-hook] [--force]"
---

Initialize receipts in the user's current repository:
Expand All @@ -9,7 +9,28 @@ Initialize receipts in the user's current repository:
sh "${CLAUDE_PLUGIN_ROOT}/core/py.sh" "${CLAUDE_PLUGIN_ROOT}/core/receipt.py" init $ARGUMENTS
```

After it succeeds, explain the three facts the user must not learn later:
**Before running without `--ssh`,** check whether the user has an SSH key
(`git config gpg.format` is `ssh`, or `~/.ssh/id_ed25519.pub` exists). If they
do, recommend `--ssh`: each receipt is then signed by their own key, there's
no secret to share, and fresh clones and CI can verify signatures. Ask; don't
choose for them. In a repo that already has `.titan/allowed_signers`, plain
init uses SSH automatically.

After it succeeds, explain the three facts the user must not learn later.

**With `--ssh`:**

1. Their SSH public key and git email were added to `.titan/allowed_signers`.
Commit that file with the receipts: it says who may sign, and a change to
it shows in git history. The private key never leaves their machine; the
key path is in this clone's local git config only.
2. Receipts land in `.titan/attestations/` and the verifier is vendored into
`.titan/tools/`. Both SHOULD be committed.
3. Each teammate runs `/receipt-init --ssh` in their own clone; it adds their
key beside the others. A key with a passphrase must be loaded in
ssh-agent (`ssh-add`), because hooks can't prompt.

**Without `--ssh` (HMAC):**

1. `.titan/key` is the shared signing key. It is gitignored and must NEVER
be committed — receipts are only as trustworthy as this key is private.
Expand All @@ -24,6 +45,13 @@ After it succeeds, explain the three facts the user must not learn later:
`python3 .titan/tools/receipt.py install-hook` once per clone. Commits
made without either hook have no receipt.

If `--ssh` refuses with "SSH key not usable", nothing was written. Relay the
reason (no key found, or it needs a passphrase): `ssh-add` the key, or pass
another with `--ssh PATH`. A repo that has moved to SSH has no HMAC
fallback: verify treats an HMAC receipt after the first SSH one as a
signature downgrade, so commits are refused a receipt until this clone
runs `/receipt-init --ssh`.

If init refuses because the key is tracked by git, that is fail-closed
working as designed: help them run `git rm --cached .titan/key`, then retry.
A key that has ever been pushed is burned — rotate it (`init --force`) and
Expand Down
Loading
Loading