From 3d4034f9b42350d16059f4429b27881b09353f58 Mon Sep 17 00:00:00 2001 From: Rehan Masood Date: Wed, 21 Oct 2026 19:49:01 -0400 Subject: [PATCH] v1.3.2: stop reading session-id environment variables The directory still holds the plugin under MCP_FORWARDS_CREDENTIAL_ENV. 1.3.1 only moved URLs away from the CLAUDE_*_SESSION_ID reads; the reads themselves are what the rule treats as a credential. They were a fallback: the PostToolUse hook payload already carries session_id, and Claude Code writes a Claude-Session trailer. So drop the reads instead of rearranging them. - core/receipt.py: no CLAUDE_CODE_SESSION_ID / CLAUDE_SESSION_ID reads; session id from the hook payload or the Claude-Session trailer only. - tests: env session ids are ignored, trailer still recorded, and no shipped file (core/, hooks/, action.yml) names a SESSION_ID variable. - README disclosure, badge.py note, CHANGELOG, version 1.3.2. - 125 tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BphvC1wMrXK1TseCjhappZ --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 24 +++++++++++++++++++++++- README.md | 6 ++++-- core/badge.py | 8 ++++---- core/receipt.py | 17 ++++++----------- tests/test_provenance.py | 29 +++++++++++++++++++++++++++++ tests/test_structure.py | 10 ++++++++++ 7 files changed, 77 insertions(+), 19 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index beee20f..1e3c214 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "commitproof", "displayName": "CommitProof: AI Audit Trail", - "version": "1.3.1", + "version": "1.3.2", "description": "Works in Claude Code. 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 a change-control report you can give SOC 2, HIPAA or PCI auditors as supporting evidence.", "author": { "name": "Rehan Masood", diff --git a/CHANGELOG.md b/CHANGELOG.md index fa667d5..ea6ccbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,28 @@ # Changelog -## Unreleased +## 1.3.2 — 2026-10-21 + +The plugin no longer reads any session-id environment variable. The +directory held 1.3.1 under `MCP_FORWARDS_CREDENTIAL_ENV`, which treats a +`*_SESSION_ID` variable as a credential. 1.3.1 kept the reads and only moved +URLs away from them; that did not clear the hold. Nothing was ever sent +anywhere (the plugin makes no network calls), but a session id has no +business being read from the environment when better sources exist. + +- `core/receipt.py`: `CLAUDE_CODE_SESSION_ID` and `CLAUDE_SESSION_ID` are no + longer read. A receipt records a session id only from the Claude Code hook + payload or a `Claude-Session:` commit trailer. Receipts written by the + PostToolUse hook are unchanged. A manual `/receipt` inside Claude Code + records the session only if the commit carries the trailer (Claude Code + adds it by default). `attribution.source` is now always `commit-trailers`; + the old `commit-trailers+env` value no longer appears in new receipts. + Old receipts still verify. +- Tests: the environment is never read for a session id, the trailer is + still recorded, and no shipped file names a `SESSION_ID` variable. +- Version is now set per release. Three different 1.3.1 commits reached the + directory; from here every shipped change bumps the version. + +### Rename migration Installs made under the old name (`titan-receipts@titan-receipts-marketplace`) stopped loading after a marketplace update once the plugin became diff --git a/README.md b/README.md index 4b194f2..eb04b49 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ 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.3.2 with: key: ${{ secrets.TITAN_KEY }} # omit for structure-only verification report: docs/ai-change-receipts.md # optional @@ -195,7 +195,9 @@ lines, because a receipt system that overclaims is worse than none. commands, hashes the changed files, and reads the Claude Code session 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/`. + into the receipt. The session id comes from the hook payload or a + `Claude-Session:` commit trailer; no session-id environment variable is + read. 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`. diff --git a/core/badge.py b/core/badge.py index 93049eb..e1a23ac 100644 --- a/core/badge.py +++ b/core/badge.py @@ -1,10 +1,10 @@ """Self-contained README badge (no badge service, nothing phones home). Kept in its own module on purpose: this file holds the plugin's homepage -URL and reads NOTHING from the environment; receipt.py reads session and -version variables and holds no URLs. The directory's credential-forward -scan matches a file that does both, and this split keeps that honest -separation visible in the layout rather than only in the code.""" +URL and reads NOTHING from the environment; receipt.py holds no URLs and, +since 1.3.2, reads no session-id variable at all (session ids come from the +hook payload or a Claude-Session trailer). The split keeps the separation +visible in the layout rather than only in the code.""" from __future__ import annotations import html diff --git a/core/receipt.py b/core/receipt.py index ae3d19c..306cb17 100644 --- a/core/receipt.py +++ b/core/receipt.py @@ -88,7 +88,7 @@ SCHEMA = "change-attestation/v1" SIGNING = "hmac-sha256-v1" -PLUGIN_VERSION = "1.3.1" # kept equal to .claude-plugin/plugin.json (tested) +PLUGIN_VERSION = "1.3.2" # kept equal to .claude-plugin/plugin.json (tested) KEY_PATH = Path(".titan/key") TREE = Path(".titan/attestations") TOOLS_DIR = Path(".titan/tools") @@ -372,10 +372,6 @@ def _attribution(subject_body: str) -> dict: out["assisted_by"] = asst if ses: out["session"] = ses[0] - for env in ("CLAUDE_SESSION_ID",): - if os.environ.get(env): - out.setdefault("session", os.environ[env]) - out["source"] = "commit-trailers+env" return out @@ -441,13 +437,12 @@ def _provenance(msg: str, recorder: str, hook: dict | None) -> dict: "recorder_version": PLUGIN_VERSION, } hook = hook or {} - # Environment is trusted only when we are demonstrably inside a Claude - # Code session; a git post-commit hook from a human terminal must never - # inherit a stale session id from the environment. + # A session id comes only from the Claude Code hook payload or a + # Claude-Session commit trailer. The environment is never read for it. + # The version below is read from the environment only when we are + # demonstrably inside a Claude Code session. in_claude = bool(os.environ.get("CLAUDECODE")) - session = hook.get("session_id") or ( - in_claude and (os.environ.get("CLAUDE_CODE_SESSION_ID") - or os.environ.get("CLAUDE_SESSION_ID"))) + session = hook.get("session_id") if session: prov["session_id"] = str(session) elif ses_trailer: diff --git a/tests/test_provenance.py b/tests/test_provenance.py index 0e030ec..a231913 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -121,6 +121,35 @@ def test_non_claude_coauthor_is_not_ai(repo): assert receipts(repo)[0]["provenance"]["ai_assisted"] is False +def test_session_id_environment_variables_are_never_read(repo): + """v1.3.2: the plugin does not read session-id environment variables. + The directory treats them as credentials. A session id comes only from + the Claude Code hook payload or a Claude-Session commit trailer, both + of which the user's own tooling put there.""" + run(["init", "--no-git-hook"], repo) + (repo / "b.py").write_text("x\n") + sh("git add -A && git commit -qm 'ai edit' -m 'Co-Authored-By: Claude '", repo) + env = clean_env(CLAUDECODE="1", CLAUDE_CODE_SESSION_ID="env-a", CLAUDE_SESSION_ID="env-b") + r = run(["create"], repo, env=env) + assert r.returncode == 0, r.stderr + rc = receipts(repo)[0] + assert "session_id" not in rc["provenance"] + assert "session" not in rc["attribution"] + assert rc["attribution"]["source"] == "commit-trailers" + assert "env-a" not in json.dumps(rc) and "env-b" not in json.dumps(rc) + + +def test_session_trailer_is_still_recorded_without_the_environment(repo): + run(["init", "--no-git-hook"], repo) + (repo / "b.py").write_text("x\n") + sh("git add -A && git commit -qm 'ai edit' -m 'Claude-Session: sess-trailer-1'", repo) + r = run(["create"], repo) + assert r.returncode == 0, r.stderr + rc = receipts(repo)[0] + assert rc["provenance"]["session_id"] == "sess-trailer-1" + assert rc["attribution"]["session"] == "sess-trailer-1" + + # ------------------------------------------------------- claude-code hook def test_claude_hook_records_session_model_version_transcript(repo, tmp_path): run(["init", "--no-git-hook"], repo) diff --git a/tests/test_structure.py b/tests/test_structure.py index 0decb57..d149f0d 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -71,6 +71,16 @@ def test_core_is_stdlib_only(): assert not banned.search(p.read_text()), p +def test_shipped_code_reads_no_session_id_variable(): + """The directory's MCP_FORWARDS_CREDENTIAL_ENV rule treats *_SESSION_ID + environment variables as credentials. Nothing the plugin runs (core/, + hooks/, the Action) may name one, so the rule has nothing to match.""" + shipped = [*(ROOT / "core").glob("*.py"), *(ROOT / "core").glob("*.sh"), + ROOT / "hooks" / "hooks.json", ROOT / "action.yml"] + for p in shipped: + assert "SESSION_ID" not in p.read_text(), p + + def test_no_claude_md_at_root(): assert not (ROOT / "CLAUDE.md").exists()