From a4fdaa030781c1a22bc22dd14a300edd931a8219 Mon Sep 17 00:00:00 2001 From: benzzy1287 Date: Fri, 14 Aug 2026 23:47:10 +0800 Subject: [PATCH] Add reusable GitHub Action for v0.3.0 --- .codex-plugin/plugin.json | 6 +- .github/ISSUE_TEMPLATE/proofkit-feedback.yml | 56 +++++++ .github/workflows/ci.yml | 33 +++- AGENTS.md | 2 +- CHANGELOG.md | 8 + CONTRIBUTING.md | 4 +- README.md | 50 +++++- README.zh-CN.md | 43 +++++- action.yml | 145 ++++++++++++++++++ skills/prove-maintainer-work/SKILL.md | 5 + .../prove-maintainer-work/scripts/proofkit.py | 139 +++++++++++++++-- tests/test_proofkit.py | 102 ++++++++++++ 12 files changed, 570 insertions(+), 23 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/proofkit-feedback.yml create mode 100644 action.yml diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 03fbd0e..d9ef6b9 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codex-proofkit", - "version": "0.2.0", - "description": "Proof-carrying maintainer workflows for Codex: audit agent configuration, hash delivery evidence, and verify handoffs.", + "version": "0.3.0", + "description": "Proof-carrying maintainer workflows for Codex and GitHub Actions: audit release surfaces, hash evidence, and verify handoffs.", "author": { "name": "benzzy1287", "url": "https://github.com/benzzy1287" @@ -20,7 +20,7 @@ "interface": { "displayName": "Codex ProofKit", "shortDescription": "Verifiable receipts for agent-assisted maintainer work.", - "longDescription": "Audit the exact Git-tracked release surface, fail CI on privacy warnings, bind artifacts to real evidence with SHA-256 receipts, and detect drift before review or release.", + "longDescription": "Audit the exact Git-tracked release surface from Codex or a reusable GitHub Action, write privacy-safe CI summaries, bind artifacts to real evidence with SHA-256 receipts, and detect drift before review or release.", "developerName": "Codex ProofKit maintainers", "category": "Developer Tools", "capabilities": [ diff --git a/.github/ISSUE_TEMPLATE/proofkit-feedback.yml b/.github/ISSUE_TEMPLATE/proofkit-feedback.yml new file mode 100644 index 0000000..801d2b3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/proofkit-feedback.yml @@ -0,0 +1,56 @@ +name: ProofKit maintainer feedback +description: Share a privacy-safe report from a public or synthetic repository. +title: "[ProofKit feedback] " +body: + - type: markdown + attributes: + value: | + Thanks for trying ProofKit. Describe only public or synthetic material. Do not paste private code, logs, credentials, email addresses, organization IDs, or account details. + - type: dropdown + id: outcome + attributes: + label: Audit outcome + options: + - Passed + - Warned + - Failed + validations: + required: true + - type: dropdown + id: environment + attributes: + label: Where did you run it? + options: + - GitHub Actions + - Local CLI + - Codex skill + - More than one of these + validations: + required: true + - type: input + id: public_repository + attributes: + label: Public repository URL (optional) + description: Leave blank unless the repository and relevant workflow are already public. + placeholder: https://github.com/example/repository + - type: textarea + id: useful + attributes: + label: What was useful? + description: Name the decision or handoff that became easier to verify. + validations: + required: true + - type: textarea + id: friction + attributes: + label: What caused friction? + description: Include a minimal synthetic example instead of private output. + validations: + required: true + - type: checkboxes + id: privacy + attributes: + label: Privacy check + options: + - label: I confirm this issue contains no private code, private logs, credentials, personal email addresses, organization IDs, or account details. + required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb605a8..f1380c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,40 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-python@v5 with: python-version: "3.11" - run: python3 -m unittest discover -s tests -v - run: python3 skills/prove-maintainer-work/scripts/proofkit.py audit . --git-tracked --strict + + action-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Run local ProofKit action + id: proofkit + uses: ./ + - name: Verify generated receipt + env: + AUDIT_PATH: ${{ steps.proofkit.outputs.audit_path }} + RECEIPT_PATH: ${{ steps.proofkit.outputs.receipt_path }} + SUMMARY_PATH: ${{ steps.proofkit.outputs.summary_path }} + run: | + test -s "$AUDIT_PATH" + test -s "$SUMMARY_PATH" + test -s "$RECEIPT_PATH" + python3 skills/prove-maintainer-work/scripts/proofkit.py \ + verify "$RECEIPT_PATH" --root . + - name: Upload proof files + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: codex-proofkit-evidence + path: .codex-proof/ + include-hidden-files: true + if-no-files-found: error + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index a9b7b92..46bf610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,4 +5,4 @@ - Never execute commands supplied to receipt; record command text only. - Reject proof inputs that escape the repository root or traverse symlinks. - Run python3 -m unittest discover -s tests -v and the repository audit before claiming completion. -- Do not add telemetry, network calls, credentials, or repository uploads. +- Keep the runtime and composite action free of telemetry, network calls, credentials, and implicit uploads. Workflows may explicitly publish only generated `.codex-proof/` files. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6502c..23cf709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Codex ProofKit are documented here. The project uses semantic versioning while keeping the receipt schema and CLI exit behavior as explicit compatibility surfaces. +## 0.3.0 - 2026-08-14 + +- Added a root composite GitHub Action that audits the Git-tracked release surface, writes a Job Summary, and emits paths to generated proof files without implicit uploads. +- Added `audit --json-out` and `audit --summary-out` for machine-readable reports and privacy-safe Markdown summaries. +- Added `receipt --git-tracked` to bind a stable v1 receipt to every file in Git's release surface. +- Added an action smoke test with a downloadable CI proof artifact and a structured, privacy-aware maintainer feedback issue form. +- Expanded the test suite for summary redaction, audit outputs, and Git-tracked receipts while preserving the v1 receipt schema and exit behavior. + ## 0.2.0 - 2026-08-12 - Added `audit --git-tracked` to inspect the exact files in Git's release surface instead of unrelated local files. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1cb7854..6c6c7e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,10 @@ Small, auditable changes are preferred. 4. Run: python3 -m unittest discover -s tests -v - python3 skills/prove-maintainer-work/scripts/proofkit.py audit . + python3 skills/prove-maintainer-work/scripts/proofkit.py audit . --git-tracked --strict 5. Explain compatibility impact when changing a receipt field or exit code. Never commit live credentials, private repositories, proprietary source material, or personal evidence logs. + +Public or synthetic usage reports are welcome through the ProofKit feedback issue form. Never attach private source, logs, account details, email addresses, organization IDs, or credentials. diff --git a/README.md b/README.md index 7fc9048..23319cb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,38 @@ Codex ProofKit is a small, offline plugin for proof-carrying open-source mainten It uses the Python standard library, makes no network requests, and never executes command text supplied to a receipt. -Current release: 0.2.0. See [CHANGELOG.md](CHANGELOG.md) for the public maintenance history. +Current release: 0.3.0. See [CHANGELOG.md](CHANGELOG.md) for the public maintenance history. + +## GitHub Action + +Add the gate after checkout. The action audits the exact Git-tracked release surface, writes a Markdown Job Summary, and creates machine-readable proof files under `.codex-proof/`: + +```yaml +permissions: + contents: read + +steps: + - uses: actions/checkout@v5 + - id: proofkit + uses: benzzy1287/codex-proofkit@v0.3.0 +``` + +The default gate fails on errors and warnings. Set `strict: "false"` to allow warnings, or `receipt: "false"` to skip the full Git-tracked receipt. Outputs include `status`, `evidence_dir`, `audit_path`, `summary_path`, and `receipt_path`. + +ProofKit does not upload anything implicitly. To make only the generated proof files downloadable from a workflow run, add this explicit step: + +```yaml + - name: Upload ProofKit evidence + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: codex-proofkit-evidence + path: ${{ steps.proofkit.outputs.evidence_dir }} + include-hidden-files: true + if-no-files-found: error +``` + +The composite action requires Bash, Git, and Python 3, and is tested on GitHub-hosted Ubuntu runners. ## 60-second release gate @@ -60,6 +91,19 @@ Create a receipt after running the real project checks: --evidence evidence/tests.log \ --command "python3 -m unittest discover -s tests -v" +Or hash the complete Git-tracked release surface: + + python3 skills/prove-maintainer-work/scripts/proofkit.py receipt . \ + --git-tracked \ + --command "python3 -m unittest discover -s tests -v" + +Write JSON and Markdown audit outputs for another CI system: + + python3 skills/prove-maintainer-work/scripts/proofkit.py audit . \ + --git-tracked --strict \ + --json-out .codex-proof/audit.json \ + --summary-out .codex-proof/summary.md + Verify it: python3 skills/prove-maintainer-work/scripts/proofkit.py verify \ @@ -83,10 +127,12 @@ The repository includes a .codex-plugin/plugin.json manifest and the prove-maint ## Privacy and security -ProofKit is local-only. It has no telemetry, credentials, connector, paid service, or upload path. It rejects proof inputs outside the repository and rejects symlinks to avoid binding a receipt to unexpected files. Findings name the file and risk class but do not echo matched email addresses, usernames, or secret values. +The ProofKit CLI and composite action are local-only. They have no telemetry, credentials, connector, paid service, network call, or implicit upload path. An optional workflow step can explicitly upload only generated proof files. ProofKit rejects proof inputs outside the repository and rejects symlinks to avoid binding a receipt to unexpected files. Findings name the file and risk class but do not echo matched email addresses, usernames, or secret values. Report vulnerabilities privately as described in SECURITY.md. Do not paste live credentials into an issue. +Tried ProofKit on a public or synthetic repository? [Share a short maintainer feedback report](https://github.com/benzzy1287/codex-proofkit/issues/new?template=proofkit-feedback.yml). Do not include private code, logs, credentials, email addresses, organization IDs, or account details. + ## License and name MIT licensed. Codex is a trademark of OpenAI. This independent project is not affiliated with or endorsed by OpenAI. diff --git a/README.zh-CN.md b/README.zh-CN.md index a187999..8b99320 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,7 +12,38 @@ Codex ProofKit 是一个离线、零第三方依赖的 Codex plugin,用来给 - 把真实测试日志、审查记录与交付文件写进同一份 SHA-256 回执; - 在合并或发布前验证文件是否已经漂移。 -当前版本:0.2.0。 +当前版本:0.3.0。 + +## GitHub Action + +在 checkout 后加入 ProofKit,即可审计 Git 已跟踪的发布面、生成 GitHub Job Summary,并在 `.codex-proof/` 写出机器可读的审计和回执: + +```yaml +permissions: + contents: read + +steps: + - uses: actions/checkout@v5 + - id: proofkit + uses: benzzy1287/codex-proofkit@v0.3.0 +``` + +默认会让错误和警告都阻断 CI。可用 `strict: "false"` 放行警告,或用 `receipt: "false"` 跳过完整的 Git-tracked 哈希回执。输出包括 `status`、`evidence_dir`、`audit_path`、`summary_path` 和 `receipt_path`。 + +Action 不会隐式上传任何内容。如需在一次 workflow run 中下载审计与回执,请显式添加下面这一步;它只上传生成的 proof 文件,不上传源码: + +```yaml + - name: Upload ProofKit evidence + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: codex-proofkit-evidence + path: ${{ steps.proofkit.outputs.evidence_dir }} + include-hidden-files: true + if-no-files-found: error +``` + +该 composite action 需要 Bash、Git 和 Python 3,目前在 GitHub 托管的 Ubuntu runner 上验证。 ## 60 秒发布闸门 @@ -46,9 +77,17 @@ Codex ProofKit 是一个离线、零第三方依赖的 Codex plugin,用来给 --evidence evidence/tests.log \ --command "python3 -m unittest discover -s tests -v" +也可以直接为完整的 Git 已跟踪发布面创建回执: + + python3 skills/prove-maintainer-work/scripts/proofkit.py receipt . \ + --git-tracked \ + --command "python3 -m unittest discover -s tests -v" + 验证回执: python3 skills/prove-maintainer-work/scripts/proofkit.py verify \ .codex-proof/receipt.json --root . -项目不联网、不上传仓库、不需要凭据或付费服务,也不执行回执里的命令文本。扫描结果只报告文件和风险类型,不回显匹配到的邮箱、用户名或密钥内容。MIT 许可;本项目与 OpenAI 无隶属或背书关系。 +CLI 和 composite action 不联网、不隐式上传、不需要凭据或付费服务,也不执行回执里的命令文本。只有使用者显式添加 artifact 步骤时,生成的 proof 文件才会被上传。扫描结果只报告文件和风险类型,不回显匹配到的邮箱、用户名或密钥内容。 + +如果你在公开或合成仓库里试用了 ProofKit,可以[提交一份简短维护者反馈](https://github.com/benzzy1287/codex-proofkit/issues/new?template=proofkit-feedback.yml)。请勿附上私有代码、日志、凭据、邮箱、组织 ID 或账号信息。MIT 许可;本项目与 OpenAI 无隶属或背书关系。 diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..b4eb610 --- /dev/null +++ b/action.yml @@ -0,0 +1,145 @@ +name: Codex ProofKit +description: Audit a Git-tracked release surface and create a verifiable SHA-256 receipt. +author: benzzy1287 + +inputs: + strict: + description: Fail the audit on warnings as well as errors. + required: false + default: "true" + receipt: + description: Create a receipt that hashes every Git-tracked file. + required: false + default: "true" + +outputs: + status: + description: "Audit status: pass, warn, or fail." + value: ${{ steps.audit.outputs.status }} + evidence_dir: + description: Directory containing all generated proof files. + value: ${{ steps.proof-files.outputs.evidence_dir }} + audit_path: + description: Path to the machine-readable JSON audit. + value: ${{ steps.proof-files.outputs.audit_path }} + summary_path: + description: Path to the Markdown audit summary. + value: ${{ steps.proof-files.outputs.summary_path }} + receipt_path: + description: Path to the receipt, or an empty string when receipt creation is disabled. + value: ${{ steps.receipt.outputs.path }} + +runs: + using: composite + steps: + - name: Audit Git-tracked release surface + id: audit + shell: bash + env: + PROOFKIT_STRICT: ${{ inputs.strict }} + PROOFKIT_RECEIPT: ${{ inputs.receipt }} + run: | + set -euo pipefail + + case "$PROOFKIT_STRICT" in + true|false) ;; + *) echo "proofkit: strict must be true or false" >&2; exit 2 ;; + esac + case "$PROOFKIT_RECEIPT" in + true|false) ;; + *) echo "proofkit: receipt must be true or false" >&2; exit 2 ;; + esac + + temporary_dir="$RUNNER_TEMP/codex-proofkit" + audit_path="$temporary_dir/audit.json" + summary_path="$temporary_dir/summary.md" + mkdir -p "$temporary_dir" + + strict_args=() + if [[ "$PROOFKIT_STRICT" == "true" ]]; then + strict_args+=(--strict) + fi + + set +e + python3 "$GITHUB_ACTION_PATH/skills/prove-maintainer-work/scripts/proofkit.py" \ + audit "$GITHUB_WORKSPACE" \ + --git-tracked \ + "${strict_args[@]}" \ + --json-out "$audit_path" \ + --summary-out "$summary_path" + audit_exit=$? + set -e + + if [[ -f "$summary_path" ]]; then + cat "$summary_path" >> "$GITHUB_STEP_SUMMARY" + else + echo "## Codex ProofKit audit could not be completed" >> "$GITHUB_STEP_SUMMARY" + fi + + audit_status="error" + if [[ -f "$audit_path" ]]; then + audit_status="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["status"])' "$audit_path")" + fi + + echo "status=$audit_status" >> "$GITHUB_OUTPUT" + echo "exit_code=$audit_exit" >> "$GITHUB_OUTPUT" + echo "audit_path=$audit_path" >> "$GITHUB_OUTPUT" + echo "summary_path=$summary_path" >> "$GITHUB_OUTPUT" + + - name: Create Git-tracked receipt + id: receipt + if: ${{ inputs.receipt == 'true' }} + shell: bash + env: + PROOFKIT_STRICT: ${{ inputs.strict }} + run: | + set -euo pipefail + receipt_path="$GITHUB_WORKSPACE/.codex-proof/receipt.json" + reported_command="proofkit audit . --git-tracked" + if [[ "$PROOFKIT_STRICT" == "true" ]]; then + reported_command="$reported_command --strict" + fi + python3 "$GITHUB_ACTION_PATH/skills/prove-maintainer-work/scripts/proofkit.py" \ + receipt "$GITHUB_WORKSPACE" \ + --git-tracked \ + --command "$reported_command" \ + --out "$receipt_path" + echo "path=$receipt_path" >> "$GITHUB_OUTPUT" + + - name: Collect local proof files + id: proof-files + if: ${{ always() }} + shell: bash + env: + AUDIT_PATH: ${{ steps.audit.outputs.audit_path }} + SUMMARY_PATH: ${{ steps.audit.outputs.summary_path }} + run: | + set -euo pipefail + evidence_dir="$GITHUB_WORKSPACE/.codex-proof" + mkdir -p "$evidence_dir" + if [[ -f "$AUDIT_PATH" ]]; then + cp -- "$AUDIT_PATH" "$evidence_dir/audit.json" + fi + if [[ -f "$SUMMARY_PATH" ]]; then + cp -- "$SUMMARY_PATH" "$evidence_dir/summary.md" + fi + echo "evidence_dir=$evidence_dir" >> "$GITHUB_OUTPUT" + echo "audit_path=$evidence_dir/audit.json" >> "$GITHUB_OUTPUT" + echo "summary_path=$evidence_dir/summary.md" >> "$GITHUB_OUTPUT" + + - name: Enforce audit result + if: ${{ always() }} + shell: bash + env: + PROOFKIT_AUDIT_EXIT: ${{ steps.audit.outputs.exit_code }} + run: | + case "$PROOFKIT_AUDIT_EXIT" in + 0) exit 0 ;; + 1) exit 1 ;; + 2) exit 2 ;; + *) echo "proofkit: audit did not produce a valid exit code" >&2; exit 2 ;; + esac + +branding: + icon: check-circle + color: blue diff --git a/skills/prove-maintainer-work/SKILL.md b/skills/prove-maintainer-work/SKILL.md index dd8385a..99d12d2 100644 --- a/skills/prove-maintainer-work/SKILL.md +++ b/skills/prove-maintainer-work/SKILL.md @@ -26,6 +26,8 @@ Before a public release, audit the exact Git-tracked surface and fail on warning Stage intended changes before this check. Untracked files are deliberately excluded because they are not part of the Git release surface. +In GitHub Actions, prefer the repository's root composite action after checkout. It runs this strict Git-tracked audit, writes a Job Summary, and creates `.codex-proof/audit.json`, `.codex-proof/summary.md`, and `.codex-proof/receipt.json`. The action does not upload those files; add an explicit artifact step only when public workflow retention is appropriate. + Run the repository's own tests, linters, builds, or review commands. Save their real output inside the repository. ProofKit records command text but never executes it, so never treat a receipt as evidence that an unrun command passed. Create a receipt: @@ -36,6 +38,8 @@ Create a receipt: --command "" \ --out .codex-proof/receipt.json +To bind the receipt to the complete Git-tracked release surface instead of selected artifacts, replace the `--artifact` options with `--git-tracked`. + Verify the handoff from a clean checkout or before release: python3 "$SKILL_DIR/scripts/proofkit.py" verify \ @@ -46,6 +50,7 @@ Verify the handoff from a clean checkout or before release: - Keep proof inputs inside the repository root. - Reject symlinks as proof inputs. - Do not include secrets, credentials, paid/private source material, or personal absolute paths. +- Keep proof generation offline; make any CI artifact upload explicit and limited to `.codex-proof/`. - Use `--git-tracked --strict` as the release and CI gate. - Do not describe warnings as passes without naming the warning and rationale. - Treat a hash match as integrity evidence, not semantic quality evidence. diff --git a/skills/prove-maintainer-work/scripts/proofkit.py b/skills/prove-maintainer-work/scripts/proofkit.py index d162a83..1d3aa0d 100644 --- a/skills/prove-maintainer-work/scripts/proofkit.py +++ b/skills/prove-maintainer-work/scripts/proofkit.py @@ -6,6 +6,7 @@ import argparse import datetime as dt import hashlib +import html import json import os import re @@ -15,7 +16,7 @@ from typing import Any, Iterable -VERSION = "0.2.0" +VERSION = "0.3.0" RECEIPT_SCHEMA = "codex-proofkit-receipt-v1" DEFAULT_INSTRUCTION_LIMIT = 32_768 IGNORED_DIRS = { @@ -256,6 +257,10 @@ def scan_text_risks( for match in EMAIL_ADDRESS.finditer(text) if match.group(0).rsplit("@", 1)[1].lower() not in SAFE_EXAMPLE_EMAIL_DOMAINS + and any( + char.isalpha() + for char in match.group(0).rsplit(".", 1)[-1] + ) } if emails: findings.append( @@ -519,23 +524,60 @@ def manifest_entries(root: Path, inputs: list[str]) -> list[dict[str, Any]]: return [entries[key] for key in sorted(entries)] +def manifest_file_entries(root: Path, files: Iterable[Path]) -> list[dict[str, Any]]: + entries: dict[str, dict[str, Any]] = {} + for path in sorted(files): + rel = relative_repo_path(path, root) + if path.is_symlink(): + raise ValueError(f"symlinks are not accepted as proof inputs: {rel}") + if not path.is_file(): + raise ValueError(f"proof input is missing or is not a file: {rel}") + entries[rel] = { + "path": rel, + "bytes": path.stat().st_size, + "sha256": sha256_file(path), + } + return [entries[key] for key in sorted(entries)] + + +def write_text_atomic(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temp.write_text(text, encoding="utf-8") + os.replace(temp, path) + + +def write_json_atomic(path: Path, data: dict[str, Any]) -> None: + write_text_atomic( + path, + json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + ) + + def create_receipt( root: Path, artifacts: list[str], evidence: list[str], commands: list[str], out: Path, + git_tracked: bool = False, ) -> dict[str, Any]: root = root.resolve() - if not artifacts: - raise ValueError("at least one --artifact is required") + if git_tracked and artifacts: + raise ValueError("--git-tracked cannot be combined with --artifact") + if not git_tracked and not artifacts: + raise ValueError("at least one --artifact or --git-tracked is required") out = out.resolve() relative_repo_path(out, root) - artifact_entries = manifest_entries(root, artifacts) + artifact_entries = ( + manifest_file_entries(root, git_tracked_files(root)) + if git_tracked + else manifest_entries(root, artifacts) + ) if not artifact_entries: raise ValueError("artifact inputs did not resolve to any files") evidence_entries = manifest_entries(root, evidence) - audit = run_audit(root) + audit = run_audit(root, git_tracked=git_tracked) receipt = { "schema_version": RECEIPT_SCHEMA, "proofkit_version": VERSION, @@ -550,13 +592,7 @@ def create_receipt( "artifacts": artifact_entries, "evidence": evidence_entries, } - out.parent.mkdir(parents=True, exist_ok=True) - temp = out.with_name(f".{out.name}.{os.getpid()}.tmp") - temp.write_text( - json.dumps(receipt, ensure_ascii=False, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - os.replace(temp, out) + write_json_atomic(out, receipt) return receipt @@ -632,6 +668,62 @@ def print_audit(report: dict[str, Any]) -> None: ) +def markdown_cell(value: object) -> str: + text = str(value).replace("\r", "\\r").replace("\n", "\\n") + escaped = html.escape(text, quote=False) + for character, entity in ( + ("\\", "\"), + ("|", "|"), + ("`", "`"), + ("[", "["), + ("]", "]"), + ): + escaped = escaped.replace(character, entity) + return escaped + + +def render_audit_markdown(report: dict[str, Any]) -> str: + counts = report["counts"] + lines = [ + "# Codex ProofKit audit", + "", + f"**Status:** {markdown_cell(str(report['status']).upper())}", + "", + ( + f"Scope: `{markdown_cell(report['scope'])}` · " + f"Files: {report['scanned_files']} · " + f"Errors: {counts['error']} · Warnings: {counts['warning']} · " + f"ProofKit: `{markdown_cell(report['proofkit_version'])}`" + ), + "", + ] + findings = report["findings"] + if not findings: + lines.append("No privacy or configuration findings.") + else: + lines.extend( + [ + "| Severity | Code | File | Finding |", + "| --- | --- | --- | --- |", + ] + ) + for item in findings: + lines.append( + "| " + + " | ".join( + markdown_cell(value) + for value in ( + str(item["severity"]).upper(), + item["code"], + item.get("path", "—"), + item["message"], + ) + ) + + " |" + ) + return "\n".join(lines) + "\n" + + def print_verification(report: dict[str, Any]) -> None: print( f"Codex ProofKit verify: {report['status'].upper()} " @@ -652,6 +744,14 @@ def parser() -> argparse.ArgumentParser: audit = sub.add_parser("audit", help="Audit AGENTS.md, skills, paths, and secrets") audit.add_argument("root", nargs="?", default=".") audit.add_argument("--json", action="store_true") + audit.add_argument( + "--json-out", + help="write the audit report as JSON without changing console output", + ) + audit.add_argument( + "--summary-out", + help="write a privacy-safe Markdown summary", + ) audit.add_argument( "--git-tracked", action="store_true", @@ -670,7 +770,12 @@ def parser() -> argparse.ArgumentParser: receipt = sub.add_parser("receipt", help="Hash artifacts and evidence into a receipt") receipt.add_argument("root", nargs="?", default=".") - receipt.add_argument("--artifact", action="append", default=[], required=True) + receipt.add_argument("--artifact", action="append", default=[]) + receipt.add_argument( + "--git-tracked", + action="store_true", + help="hash every file in the Git index as the artifact set", + ) receipt.add_argument("--evidence", action="append", default=[]) receipt.add_argument("--command", dest="commands", action="append", default=[]) receipt.add_argument( @@ -697,6 +802,13 @@ def main(argv: list[str] | None = None) -> int: git_tracked=args.git_tracked, strict=args.strict, ) + if args.json_out: + write_json_atomic(Path(args.json_out).resolve(), report) + if args.summary_out: + write_text_atomic( + Path(args.summary_out).resolve(), + render_audit_markdown(report), + ) if args.json: print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) else: @@ -713,6 +825,7 @@ def main(argv: list[str] | None = None) -> int: args.evidence, args.commands, out, + git_tracked=args.git_tracked, ) if args.json: print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) diff --git a/tests/test_proofkit.py b/tests/test_proofkit.py index 6af7370..886072a 100644 --- a/tests/test_proofkit.py +++ b/tests/test_proofkit.py @@ -110,6 +110,18 @@ def test_git_tracked_strict_blocks_personal_email(self) -> None: ) self.assertNotIn("proofkit.invalid", json.dumps(strict)) + def test_action_version_reference_is_not_an_email(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.make_repo(root) + workflow = root / "workflow.yml" + workflow.write_text( + "uses: example/project@v0.3.0\n", + encoding="utf-8", + ) + report = proofkit.run_audit(root, strict=True) + self.assertEqual(report["status"], "pass") + def test_git_tracked_audit_blocks_openai_org_id(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -124,6 +136,24 @@ def test_git_tracked_audit_blocks_openai_org_id(self) -> None: {item["code"] for item in report["findings"]}, ) + def test_markdown_summary_does_not_echo_private_values(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.make_repo(root) + risky = root / "![pixel](example).txt" + risky.write_text( + "/" + "Users/alice/private.txt\n" + "maintainer" + "@" + "proofkit.invalid\n", + encoding="utf-8", + ) + summary = proofkit.render_audit_markdown(proofkit.run_audit(root)) + self.assertIn("absolute-user-path", summary) + self.assertIn("email-address", summary) + self.assertNotIn("alice", summary) + self.assertNotIn("proofkit.invalid", summary) + self.assertNotIn(str(root), summary) + self.assertNotIn("![pixel]", summary) + def test_receipt_verifies_then_detects_drift(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -180,6 +210,30 @@ def test_receipt_schema_is_stable_json(self) -> None: self.assertEqual(data["repository"]["root"], ".") self.assertNotIn(str(root), receipt_path.read_text(encoding="utf-8")) + def test_git_tracked_receipt_hashes_only_release_surface(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.make_repo(root) + self.track(root, "AGENTS.md", "skills") + untracked = root / "private.log" + untracked.write_text("not public\n", encoding="utf-8") + receipt_path = root / ".codex-proof" / "receipt.json" + receipt = proofkit.create_receipt( + root, + [], + [], + ["python3 -m unittest"], + receipt_path, + git_tracked=True, + ) + paths = {entry["path"] for entry in receipt["artifacts"]} + self.assertEqual( + paths, + {"AGENTS.md", "skills/check-work/SKILL.md"}, + ) + self.assertEqual(receipt["audit"]["scope"], "git-tracked") + self.assertNotIn("private.log", receipt_path.read_text(encoding="utf-8")) + def test_cli_receipt_and_verify(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -240,6 +294,54 @@ def test_cli_git_tracked_requires_repository(self) -> None: self.assertEqual(audited.returncode, 2) self.assertIn("cannot list Git-tracked files", audited.stderr) + def test_cli_writes_json_and_markdown_audit_outputs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.make_repo(root) + json_path = root / ".codex-proof" / "audit.json" + summary_path = root / ".codex-proof" / "summary.md" + audited = subprocess.run( + [ + "python3", + str(SCRIPT), + "audit", + str(root), + "--json-out", + str(json_path), + "--summary-out", + str(summary_path), + ], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(audited.returncode, 0, audited.stderr) + self.assertEqual(json.loads(json_path.read_text())["status"], "pass") + self.assertIn("**Status:** PASS", summary_path.read_text()) + + def test_cli_git_tracked_receipt(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.make_repo(root) + self.track(root, "AGENTS.md", "skills") + created = subprocess.run( + [ + "python3", + str(SCRIPT), + "receipt", + str(root), + "--git-tracked", + ], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(created.returncode, 0, created.stderr) + data = json.loads( + (root / ".codex-proof" / "receipt.json").read_text() + ) + self.assertEqual(len(data["artifacts"]), 2) + if __name__ == "__main__": unittest.main()