diff --git a/.github/workflows/tradernet-ltp-replay.yml b/.github/workflows/tradernet-ltp-replay.yml new file mode 100644 index 00000000..95a7f027 --- /dev/null +++ b/.github/workflows/tradernet-ltp-replay.yml @@ -0,0 +1,180 @@ +name: Tradernet Canonical LTP Replay + +on: + workflow_dispatch: + inputs: + expected_sha: + required: false + type: string + pull_request: + branches: [main] + paths: + - .github/workflows/tradernet-ltp-replay.yml + - audits/tradernet/terminal-loading-public.json + - scripts/tradernet_terminal_*.mjs + - scripts/write_*manifest.py + - scripts/write_ltp_audit_trace.py + - scripts/run_ltp_offline_replay.sh + - tests/test_write_*.py + +permissions: + contents: read + +concurrency: + group: tradernet-ltp-${{ github.ref }} + cancel-in-progress: true + +jobs: + replay: + name: LTP replay - ${{ matrix.id }} + runs-on: ubuntu-latest + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - id: loading + audit: Tradernet public terminal loading audit + dir: tradernet-ltp-loading + prefix: tradernet-ltp-loading + - id: image + audit: Tradernet mobile image visibility audit + dir: tradernet-ltp-image + prefix: tradernet-ltp-image + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || inputs.expected_sha || github.sha }} + LTP_SHA: 5474f29021adf1fd9257f7d8375fedc485d00352 + EVIDENCE_NAME: ${{ matrix.prefix }}-${{ github.run_id }}-${{ github.run_attempt }} + RECEIPT_NAME: ${{ matrix.prefix }}-receipt-${{ github.run_id }}-${{ github.run_attempt }} + NPM_CONFIG_AUDIT: "false" + NPM_CONFIG_FUND: "false" + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ env.EXPECTED_SHA }} + fetch-depth: 1 + persist-credentials: false + + - name: Verify identity, boundary and helpers + id: identity + env: + PYTHONDONTWRITEBYTECODE: "1" + shell: bash + run: | + set -euo pipefail + [[ "${EXPECTED_SHA}" =~ ^[0-9a-f]{40}$ ]] + initial_sha="$(git rev-parse HEAD)" + test "${initial_sha}" = "${EXPECTED_SHA}" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + jq -e '.target_url=="https://tradernet.ru/terminal" and .observation_ms<=30000 and .boundaries.public_page_only==true and .boundaries.authenticated_testing==false and .boundaries.financial_operations==false and .boundaries.order_entry==false and .boundaries.form_submission==false and .boundaries.fuzzing==false and .boundaries.load_testing==false and .boundaries.active_security_testing==false' audits/tradernet/terminal-loading-public.json >/dev/null + python3 -m unittest tests/test_write_exact_head_manifest.py tests/test_write_ltp_audit_trace.py -v + bash -n scripts/run_ltp_offline_replay.sh + test -z "$(find scripts tests -type d -name __pycache__ -print -quit)" + echo "initial_sha=${initial_sha}" >> "${GITHUB_OUTPUT}" + echo "started_at=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "${GITHUB_OUTPUT}" + + - name: Install browser dependency outside worktree + shell: bash + run: | + set -euo pipefail + deps="${RUNNER_TEMP}/browser-${{ matrix.id }}" + npm install --prefix "${deps}" --no-save --package-lock=false puppeteer-core@24.16.0 + ln -s "${deps}/node_modules" node_modules + + - name: Capture public evidence + id: capture + shell: bash + run: | + set -euo pipefail + out="${RUNNER_TEMP}/${{ matrix.dir }}" + rm -rf "${out}" && mkdir -p "${out}" + chrome="$(command -v google-chrome-stable || command -v google-chrome || command -v chromium || command -v chromium-browser)" + if [ "${{ matrix.id }}" = loading ]; then + node scripts/tradernet_terminal_loading_observer.mjs --config audits/tradernet/terminal-loading-public.json --chrome "${chrome}" --output-dir "${out}" + else + node scripts/tradernet_terminal_image_visibility_probe.mjs --config audits/tradernet/terminal-loading-public.json --chrome "${chrome}" --output-dir "${out}" + fi + rm node_modules + + - name: Prepare frozen inspector + id: ltp + shell: bash + run: | + set -euo pipefail + d="${RUNNER_TEMP}/ltp-${{ matrix.id }}" + git init "${d}" + git -C "${d}" remote add origin https://github.com/safal207/L-THREAD-Liminal-Thread-Secure-Protocol-LTP-.git + git -C "${d}" fetch --depth=1 origin "${LTP_SHA}" + git -C "${d}" checkout --detach FETCH_HEAD + test "$(git -C "${d}" rev-parse HEAD)" = "${LTP_SHA}" + corepack enable + corepack prepare pnpm@9.15.0 --activate + (cd "${d}" && pnpm install --frozen-lockfile --ignore-scripts) + echo "dir=${d}" >> "${GITHUB_OUTPUT}" + + - name: Build trace and replay twice + id: audit + shell: bash + run: | + set -euo pipefail + out="${RUNNER_TEMP}/${{ matrix.dir }}" + registry="${{ steps.ltp.outputs.dir }}/docs/contracts/ltp-critical-actions.v0.1.json" + python3 scripts/write_ltp_audit_trace.py build --output-dir "${out}" --audit-name "${{ matrix.audit }}" --target https://tradernet.ru/terminal --repository "${GITHUB_REPOSITORY}" --expected-sha "${EXPECTED_SHA}" --initial-sha "${{ steps.identity.outputs.initial_sha }}" --workflow-sha "${{ github.workflow_sha }}" --run-id "${GITHUB_RUN_ID}" --run-attempt "${GITHUB_RUN_ATTEMPT}" --started-at "${{ steps.identity.outputs.started_at }}" --capture-status "${{ steps.capture.outcome }}" --artifact-name "${EVIDENCE_NAME}" --ltp-sha "${LTP_SHA}" --critical-actions-registry "${registry}" + bash scripts/run_ltp_offline_replay.sh "${out}/ltp/trace.jsonl" "${out}" "${{ steps.ltp.outputs.dir }}" "${LTP_SHA}" + + - name: Upload failed LTP diagnostics + if: always() && steps.audit.outcome == 'failure' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.prefix }}-debug-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/${{ matrix.dir }}/ltp/ + if-no-files-found: warn + retention-days: 7 + + - name: Verify final exact head + id: final + if: always() + shell: bash + run: | + set -euo pipefail + final_sha="$(git rev-parse HEAD)" + test "${final_sha}" = "${EXPECTED_SHA}" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + echo "final_sha=${final_sha}" >> "${GITHUB_OUTPUT}" + echo "completed_at=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "${GITHUB_OUTPUT}" + + - name: Write manifest + id: manifest + if: always() && steps.audit.outcome == 'success' && steps.final.outcome == 'success' + shell: bash + run: | + out="${RUNNER_TEMP}/${{ matrix.dir }}" + python3 scripts/write_exact_head_manifest.py manifest --output-dir "${out}" --audit-name "${{ matrix.audit }}" --target https://tradernet.ru/terminal --repository "${GITHUB_REPOSITORY}" --expected-sha "${EXPECTED_SHA}" --initial-sha "${{ steps.identity.outputs.initial_sha }}" --final-sha "${{ steps.final.outputs.final_sha }}" --workflow-sha "${{ github.workflow_sha }}" --event-name "${GITHUB_EVENT_NAME}" --git-ref "${GITHUB_REF}" --head-ref "${GITHUB_HEAD_REF}" --workflow-ref "${{ github.workflow_ref }}" --run-id "${GITHUB_RUN_ID}" --run-attempt "${GITHUB_RUN_ATTEMPT}" --artifact-name "${EVIDENCE_NAME}" --started-at "${{ steps.identity.outputs.started_at }}" --completed-at "${{ steps.final.outputs.completed_at }}" --execution-status "capture=${{ steps.capture.outcome }};ltp=${{ steps.audit.outcome }}" + + - name: Upload evidence + id: upload + if: always() && steps.manifest.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: ${{ env.EVIDENCE_NAME }} + path: ${{ runner.temp }}/${{ matrix.dir }}/ + if-no-files-found: error + retention-days: 14 + + - name: Write and upload receipt + if: always() && steps.upload.outcome == 'success' + shell: bash + run: | + set -euo pipefail + out="${RUNNER_TEMP}/${{ matrix.dir }}-receipt" + mkdir -p "${out}" + python3 scripts/write_exact_head_manifest.py receipt --manifest "${RUNNER_TEMP}/${{ matrix.dir }}/manifest.json" --output "${out}/artifact-receipt.json" --artifact-name "${EVIDENCE_NAME}" --artifact-id "${{ steps.upload.outputs.artifact-id }}" --artifact-url "${{ steps.upload.outputs.artifact-url }}" --artifact-digest "${{ steps.upload.outputs.artifact-digest }}" --run-id "${GITHUB_RUN_ID}" --run-attempt "${GITHUB_RUN_ATTEMPT}" + + - uses: actions/upload-artifact@v4 + if: always() && steps.upload.outcome == 'success' + with: + name: ${{ env.RECEIPT_NAME }} + path: ${{ runner.temp }}/${{ matrix.dir }}-receipt/artifact-receipt.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/tradernet-terminal-image-visibility.yml b/.github/workflows/tradernet-terminal-image-visibility.yml index dc4430e4..d32449dc 100644 --- a/.github/workflows/tradernet-terminal-image-visibility.yml +++ b/.github/workflows/tradernet-terminal-image-visibility.yml @@ -2,6 +2,11 @@ name: Tradernet Terminal Mobile Image Visibility on: workflow_dispatch: + inputs: + expected_sha: + description: Optional exact 40-character revision; defaults to the selected workflow ref + required: false + type: string push: branches: - agent/tradernet-terminal-loading-audit @@ -9,6 +14,8 @@ on: - .github/workflows/tradernet-terminal-image-visibility.yml - audits/tradernet/terminal-loading-public.json - scripts/tradernet_terminal_image_visibility_probe.mjs + - scripts/write_exact_head_manifest.py + - tests/test_write_exact_head_manifest.py pull_request: branches: - main @@ -16,6 +23,8 @@ on: - .github/workflows/tradernet-terminal-image-visibility.yml - audits/tradernet/terminal-loading-public.json - scripts/tradernet_terminal_image_visibility_probe.mjs + - scripts/write_exact_head_manifest.py + - tests/test_write_exact_head_manifest.py permissions: contents: read @@ -32,12 +41,34 @@ jobs: env: NPM_CONFIG_AUDIT: "false" NPM_CONFIG_FUND: "false" + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || inputs.expected_sha || github.sha }} + EVIDENCE_ARTIFACT_NAME: tradernet-terminal-mobile-image-${{ github.run_id }}-${{ github.run_attempt }} + RECEIPT_ARTIFACT_NAME: tradernet-terminal-mobile-image-receipt-${{ github.run_id }}-${{ github.run_attempt }} steps: - - uses: actions/checkout@v6 + - name: Checkout exact audited revision + uses: actions/checkout@v6 with: + ref: ${{ env.EXPECTED_SHA }} + fetch-depth: 1 persist-credentials: false + - name: Verify initial exact head + id: identity + shell: bash + run: | + set -euo pipefail + if [[ ! "${EXPECTED_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "EXPECTED_SHA must be a lowercase 40-character SHA" >&2 + exit 2 + fi + initial_sha="$(git rev-parse HEAD)" + test "${initial_sha}" = "${EXPECTED_SHA}" + worktree_status="$(git status --porcelain=v1 --untracked-files=all)" + test -z "${worktree_status}" + echo "initial_sha=${initial_sha}" >> "${GITHUB_OUTPUT}" + echo "started_at=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "${GITHUB_OUTPUT}" + - name: Validate exact boundary shell: bash run: | @@ -60,8 +91,30 @@ jobs: .boundaries.active_security_testing == false ' audits/tradernet/terminal-loading-public.json >/dev/null - - name: Install pinned browser driver - run: npm install --no-save --package-lock=false puppeteer-core@24.16.0 + - name: Validate evidence manifest helper + env: + PYTHONDONTWRITEBYTECODE: "1" + run: | + set -euo pipefail + python3 - <<'PY' + import ast + from pathlib import Path + + ast.parse(Path("scripts/write_exact_head_manifest.py").read_text(encoding="utf-8")) + PY + python3 -m unittest tests/test_write_exact_head_manifest.py -v + test -z "$(find scripts tests -type d -name __pycache__ -print -quit)" + + - name: Install pinned browser driver outside the worktree + shell: bash + run: | + set -euo pipefail + deps_dir="${RUNNER_TEMP}/tradernet-node-deps" + rm -rf "${deps_dir}" + test ! -e node_modules + test ! -L node_modules + npm install --prefix "${deps_dir}" --no-save --package-lock=false puppeteer-core@24.16.0 + ln -s "${deps_dir}/node_modules" node_modules - name: Locate Chrome and validate probe id: runtime @@ -74,21 +127,101 @@ jobs: echo "chrome=${chrome}" >> "${GITHUB_OUTPUT}" - name: Probe mobile image visibility + id: capture shell: bash run: | set -euo pipefail - rm -rf reports/tradernet-terminal-image-visibility + evidence_dir="${RUNNER_TEMP}/tradernet-terminal-image-visibility" + rm -rf "${evidence_dir}" + mkdir -p "${evidence_dir}" node scripts/tradernet_terminal_image_visibility_probe.mjs \ --config audits/tradernet/terminal-loading-public.json \ --chrome "${{ steps.runtime.outputs.chrome }}" \ - --output-dir reports/tradernet-terminal-image-visibility - cat reports/tradernet-terminal-image-visibility/terminal-image-visibility-summary.md >> "${GITHUB_STEP_SUMMARY}" + --output-dir "${evidence_dir}" + cat "${evidence_dir}/terminal-image-visibility-summary.md" >> "${GITHUB_STEP_SUMMARY}" - - name: Upload exact evidence + - name: Verify final exact head and clean worktree + id: final_identity if: always() + shell: bash + run: | + set -euo pipefail + if [ -L node_modules ]; then + rm node_modules + fi + final_sha="$(git rev-parse HEAD)" + test "${final_sha}" = "${EXPECTED_SHA}" + worktree_status="$(git status --porcelain=v1 --untracked-files=all)" + if [ -n "${worktree_status}" ]; then + printf '%s\n' "${worktree_status}" >&2 + exit 2 + fi + echo "final_sha=${final_sha}" >> "${GITHUB_OUTPUT}" + echo "completed_at=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "${GITHUB_OUTPUT}" + + - name: Write immutable evidence manifest + id: manifest + if: always() && steps.identity.outcome == 'success' && steps.final_identity.outcome == 'success' + shell: bash + run: | + set -euo pipefail + evidence_dir="${RUNNER_TEMP}/tradernet-terminal-image-visibility" + mkdir -p "${evidence_dir}" + python3 scripts/write_exact_head_manifest.py manifest \ + --output-dir "${evidence_dir}" \ + --audit-name "Tradernet terminal mobile image visibility" \ + --target "https://tradernet.ru/terminal" \ + --repository "${GITHUB_REPOSITORY}" \ + --expected-sha "${EXPECTED_SHA}" \ + --initial-sha "${{ steps.identity.outputs.initial_sha }}" \ + --final-sha "${{ steps.final_identity.outputs.final_sha }}" \ + --workflow-sha "${{ github.workflow_sha }}" \ + --event-name "${GITHUB_EVENT_NAME}" \ + --git-ref "${GITHUB_REF}" \ + --head-ref "${GITHUB_HEAD_REF}" \ + --workflow-ref "${{ github.workflow_ref }}" \ + --run-id "${GITHUB_RUN_ID}" \ + --run-attempt "${GITHUB_RUN_ATTEMPT}" \ + --artifact-name "${EVIDENCE_ARTIFACT_NAME}" \ + --started-at "${{ steps.identity.outputs.started_at }}" \ + --completed-at "${{ steps.final_identity.outputs.completed_at }}" \ + --execution-status "${{ steps.capture.outcome }}" + python3 -m json.tool "${evidence_dir}/manifest.json" >/dev/null + + - name: Upload exact evidence + id: evidence_artifact + if: always() && steps.manifest.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: ${{ env.EVIDENCE_ARTIFACT_NAME }} + path: ${{ runner.temp }}/tradernet-terminal-image-visibility/ + if-no-files-found: error + retention-days: 14 + + - name: Write artifact receipt + if: always() && steps.evidence_artifact.outcome == 'success' + shell: bash + run: | + set -euo pipefail + receipt_dir="${RUNNER_TEMP}/tradernet-terminal-image-visibility-receipt" + rm -rf "${receipt_dir}" + mkdir -p "${receipt_dir}" + python3 scripts/write_exact_head_manifest.py receipt \ + --manifest "${RUNNER_TEMP}/tradernet-terminal-image-visibility/manifest.json" \ + --output "${receipt_dir}/artifact-receipt.json" \ + --artifact-name "${EVIDENCE_ARTIFACT_NAME}" \ + --artifact-id "${{ steps.evidence_artifact.outputs.artifact-id }}" \ + --artifact-url "${{ steps.evidence_artifact.outputs.artifact-url }}" \ + --artifact-digest "${{ steps.evidence_artifact.outputs.artifact-digest }}" \ + --run-id "${GITHUB_RUN_ID}" \ + --run-attempt "${GITHUB_RUN_ATTEMPT}" + python3 -m json.tool "${receipt_dir}/artifact-receipt.json" >/dev/null + + - name: Upload artifact receipt + if: always() && steps.evidence_artifact.outcome == 'success' uses: actions/upload-artifact@v4 with: - name: tradernet-terminal-mobile-image-${{ github.run_id }} - path: reports/tradernet-terminal-image-visibility/ + name: ${{ env.RECEIPT_ARTIFACT_NAME }} + path: ${{ runner.temp }}/tradernet-terminal-image-visibility-receipt/artifact-receipt.json if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/tradernet-terminal-loading-public.yml b/.github/workflows/tradernet-terminal-loading-public.yml index fc70c223..e0a6273f 100644 --- a/.github/workflows/tradernet-terminal-loading-public.yml +++ b/.github/workflows/tradernet-terminal-loading-public.yml @@ -2,6 +2,11 @@ name: Tradernet Public Terminal Loading Audit on: workflow_dispatch: + inputs: + expected_sha: + description: Optional exact 40-character revision; defaults to the selected workflow ref + required: false + type: string push: branches: - agent/tradernet-terminal-loading-audit @@ -9,6 +14,8 @@ on: - .github/workflows/tradernet-terminal-loading-public.yml - audits/tradernet/terminal-loading-public.json - scripts/tradernet_terminal_loading_observer.mjs + - scripts/write_exact_head_manifest.py + - tests/test_write_exact_head_manifest.py pull_request: branches: - main @@ -16,6 +23,8 @@ on: - .github/workflows/tradernet-terminal-loading-public.yml - audits/tradernet/terminal-loading-public.json - scripts/tradernet_terminal_loading_observer.mjs + - scripts/write_exact_head_manifest.py + - tests/test_write_exact_head_manifest.py permissions: contents: read @@ -32,13 +41,34 @@ jobs: env: NPM_CONFIG_AUDIT: "false" NPM_CONFIG_FUND: "false" + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || inputs.expected_sha || github.sha }} + EVIDENCE_ARTIFACT_NAME: tradernet-public-terminal-loading-${{ github.run_id }}-${{ github.run_attempt }} + RECEIPT_ARTIFACT_NAME: tradernet-public-terminal-loading-receipt-${{ github.run_id }}-${{ github.run_attempt }} steps: - - name: Checkout exact workflow revision + - name: Checkout exact audited revision uses: actions/checkout@v6 with: + ref: ${{ env.EXPECTED_SHA }} + fetch-depth: 1 persist-credentials: false + - name: Verify initial exact head + id: identity + shell: bash + run: | + set -euo pipefail + if [[ ! "${EXPECTED_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "EXPECTED_SHA must be a lowercase 40-character SHA" >&2 + exit 2 + fi + initial_sha="$(git rev-parse HEAD)" + test "${initial_sha}" = "${EXPECTED_SHA}" + worktree_status="$(git status --porcelain=v1 --untracked-files=all)" + test -z "${worktree_status}" + echo "initial_sha=${initial_sha}" >> "${GITHUB_OUTPUT}" + echo "started_at=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "${GITHUB_OUTPUT}" + - name: Validate exact safety boundary shell: bash run: | @@ -64,8 +94,30 @@ jobs: .boundaries.active_security_testing == false ' "${config}" >/dev/null - - name: Install pinned browser driver - run: npm install --no-save --package-lock=false puppeteer-core@24.16.0 + - name: Validate evidence manifest helper + env: + PYTHONDONTWRITEBYTECODE: "1" + run: | + set -euo pipefail + python3 - <<'PY' + import ast + from pathlib import Path + + ast.parse(Path("scripts/write_exact_head_manifest.py").read_text(encoding="utf-8")) + PY + python3 -m unittest tests/test_write_exact_head_manifest.py -v + test -z "$(find scripts tests -type d -name __pycache__ -print -quit)" + + - name: Install pinned browser driver outside the worktree + shell: bash + run: | + set -euo pipefail + deps_dir="${RUNNER_TEMP}/tradernet-node-deps" + rm -rf "${deps_dir}" + test ! -e node_modules + test ! -L node_modules + npm install --prefix "${deps_dir}" --no-save --package-lock=false puppeteer-core@24.16.0 + ln -s "${deps_dir}/node_modules" node_modules - name: Validate observer and locate Chrome id: runtime @@ -80,21 +132,101 @@ jobs: echo "chrome=${chrome}" >> "${GITHUB_OUTPUT}" - name: Observe public terminal loading + id: capture shell: bash run: | set -euo pipefail - rm -rf reports/tradernet-terminal-loading + evidence_dir="${RUNNER_TEMP}/tradernet-terminal-loading" + rm -rf "${evidence_dir}" + mkdir -p "${evidence_dir}" node scripts/tradernet_terminal_loading_observer.mjs \ --config audits/tradernet/terminal-loading-public.json \ --chrome "${{ steps.runtime.outputs.chrome }}" \ - --output-dir reports/tradernet-terminal-loading - cat reports/tradernet-terminal-loading/result/terminal-loading-summary.md >> "${GITHUB_STEP_SUMMARY}" + --output-dir "${evidence_dir}" + cat "${evidence_dir}/result/terminal-loading-summary.md" >> "${GITHUB_STEP_SUMMARY}" - - name: Upload exact evidence + - name: Verify final exact head and clean worktree + id: final_identity if: always() + shell: bash + run: | + set -euo pipefail + if [ -L node_modules ]; then + rm node_modules + fi + final_sha="$(git rev-parse HEAD)" + test "${final_sha}" = "${EXPECTED_SHA}" + worktree_status="$(git status --porcelain=v1 --untracked-files=all)" + if [ -n "${worktree_status}" ]; then + printf '%s\n' "${worktree_status}" >&2 + exit 2 + fi + echo "final_sha=${final_sha}" >> "${GITHUB_OUTPUT}" + echo "completed_at=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "${GITHUB_OUTPUT}" + + - name: Write immutable evidence manifest + id: manifest + if: always() && steps.identity.outcome == 'success' && steps.final_identity.outcome == 'success' + shell: bash + run: | + set -euo pipefail + evidence_dir="${RUNNER_TEMP}/tradernet-terminal-loading" + mkdir -p "${evidence_dir}" + python3 scripts/write_exact_head_manifest.py manifest \ + --output-dir "${evidence_dir}" \ + --audit-name "Tradernet public terminal loading audit" \ + --target "https://tradernet.ru/terminal" \ + --repository "${GITHUB_REPOSITORY}" \ + --expected-sha "${EXPECTED_SHA}" \ + --initial-sha "${{ steps.identity.outputs.initial_sha }}" \ + --final-sha "${{ steps.final_identity.outputs.final_sha }}" \ + --workflow-sha "${{ github.workflow_sha }}" \ + --event-name "${GITHUB_EVENT_NAME}" \ + --git-ref "${GITHUB_REF}" \ + --head-ref "${GITHUB_HEAD_REF}" \ + --workflow-ref "${{ github.workflow_ref }}" \ + --run-id "${GITHUB_RUN_ID}" \ + --run-attempt "${GITHUB_RUN_ATTEMPT}" \ + --artifact-name "${EVIDENCE_ARTIFACT_NAME}" \ + --started-at "${{ steps.identity.outputs.started_at }}" \ + --completed-at "${{ steps.final_identity.outputs.completed_at }}" \ + --execution-status "${{ steps.capture.outcome }}" + python3 -m json.tool "${evidence_dir}/manifest.json" >/dev/null + + - name: Upload exact evidence + id: evidence_artifact + if: always() && steps.manifest.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: ${{ env.EVIDENCE_ARTIFACT_NAME }} + path: ${{ runner.temp }}/tradernet-terminal-loading/ + if-no-files-found: error + retention-days: 14 + + - name: Write artifact receipt + if: always() && steps.evidence_artifact.outcome == 'success' + shell: bash + run: | + set -euo pipefail + receipt_dir="${RUNNER_TEMP}/tradernet-terminal-loading-receipt" + rm -rf "${receipt_dir}" + mkdir -p "${receipt_dir}" + python3 scripts/write_exact_head_manifest.py receipt \ + --manifest "${RUNNER_TEMP}/tradernet-terminal-loading/manifest.json" \ + --output "${receipt_dir}/artifact-receipt.json" \ + --artifact-name "${EVIDENCE_ARTIFACT_NAME}" \ + --artifact-id "${{ steps.evidence_artifact.outputs.artifact-id }}" \ + --artifact-url "${{ steps.evidence_artifact.outputs.artifact-url }}" \ + --artifact-digest "${{ steps.evidence_artifact.outputs.artifact-digest }}" \ + --run-id "${GITHUB_RUN_ID}" \ + --run-attempt "${GITHUB_RUN_ATTEMPT}" + python3 -m json.tool "${receipt_dir}/artifact-receipt.json" >/dev/null + + - name: Upload artifact receipt + if: always() && steps.evidence_artifact.outcome == 'success' uses: actions/upload-artifact@v4 with: - name: tradernet-public-terminal-loading-${{ github.run_id }} - path: reports/tradernet-terminal-loading/ + name: ${{ env.RECEIPT_ARTIFACT_NAME }} + path: ${{ runner.temp }}/tradernet-terminal-loading-receipt/artifact-receipt.json if-no-files-found: error retention-days: 14 diff --git a/scripts/run_ltp_offline_replay.sh b/scripts/run_ltp_offline_replay.sh new file mode 100755 index 00000000..d690f5ee --- /dev/null +++ b/scripts/run_ltp_offline_replay.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 4 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +trace_path="$(realpath "$1")" +evidence_dir="$(realpath "$2")" +ltp_dir="$(realpath "$3")" +ltp_sha="$4" +report_dir="${evidence_dir}/ltp" +mkdir -p "${report_dir}" + +dump_failure() { + echo "--- LTP audit diagnostics ---" >&2 + for file in registry-parity.stderr.txt registry-parity.stdout.txt inspector.stderr.txt inspector-report.json replay-1.stderr.txt replay-1.stdout.txt replay-2.stderr.txt replay-2.stdout.txt explain-step-008.stderr.txt; do + if [ -s "${report_dir}/${file}" ]; then + echo "### ${file}" >&2 + cat "${report_dir}/${file}" >&2 + fi + done +} +trap dump_failure ERR + +if [[ ! "${ltp_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo "ltp-sha must be a lowercase 40-character SHA" >&2 + exit 2 +fi + +test "$(git -C "${ltp_dir}" rev-parse HEAD)" = "${ltp_sha}" +test -s "${trace_path}" + +registry_path="${ltp_dir}/docs/contracts/ltp-critical-actions.v0.1.json" +test -s "${registry_path}" +registry_sha="$(sha256sum "${registry_path}" | awk '{print $1}')" + +cat > "${report_dir}/commands.txt" < "${report_dir}/ltp-inspector-sha.txt" +printf '%s\n' "${registry_sha}" > "${report_dir}/critical-actions-registry.sha256" + +set +e +( + cd "${ltp_dir}" + pnpm exec vitest run tools/ltp-inspect/critical_actions_registry.test.ts --reporter=dot +) > "${report_dir}/registry-parity.stdout.txt" 2> "${report_dir}/registry-parity.stderr.txt" +registry_code=$? +set -e +printf '%s\n' "${registry_code}" > "${report_dir}/registry-parity.exit-code.txt" +if [ "${registry_code}" -ne 0 ]; then + echo "critical-action registry parity failed" >&2 + dump_failure + exit "${registry_code}" +fi + +set +e +( + cd "${ltp_dir}" + LTP_INSPECT_FREEZE_CLOCK=1 LTP_BUILD="${ltp_sha}" \ + pnpm exec ts-node tools/ltp-inspect/inspect.ts trace --strict --quiet --format json --color never \ + --profile agents --replay-check --input "${trace_path}" +) > "${report_dir}/inspector-report.json" 2> "${report_dir}/inspector.stderr.txt" +inspect_code=$? +set -e +printf '%s\n' "${inspect_code}" > "${report_dir}/inspector.exit-code.txt" +if [ "${inspect_code}" -ne 0 ]; then + echo "strict LTP inspection failed with exit ${inspect_code}" >&2 + dump_failure + exit "${inspect_code}" +fi + +python3 - "${report_dir}/inspector-report.json" "${ltp_sha}" <<'PY' +import json +import sys +from pathlib import Path + +report_path = Path(sys.argv[1]) +ltp_sha = sys.argv[2] +report = json.loads(report_path.read_text(encoding="utf-8")) +compliance = report.get("compliance") or {} +audit = report.get("audit_summary") or {} +assert report.get("tool", {}).get("build") == ltp_sha +assert compliance.get("profile") == "agents" +assert compliance.get("trace_integrity") == "verified" +assert compliance.get("identity_binding") == "ok" +assert compliance.get("replay_determinism") == "ok" +assert audit.get("verdict") == "PASS" +assert audit.get("failed_checks") == [] +assert audit.get("violations") == [] +PY + +for attempt in 1 2; do + set +e + ( + cd "${ltp_dir}" + LTP_BUILD="${ltp_sha}" pnpm exec ts-node tools/ltp-inspect/inspect.ts replay --color never --input "${trace_path}" + ) > "${report_dir}/replay-${attempt}.stdout.txt" 2> "${report_dir}/replay-${attempt}.stderr.txt" + replay_code=$? + set -e + printf '%s\n' "${replay_code}" > "${report_dir}/replay-${attempt}.exit-code.txt" + if [ "${replay_code}" -ne 0 ]; then + echo "replay ${attempt} failed with exit ${replay_code}" >&2 + dump_failure + exit "${replay_code}" + fi +done + +cmp --silent "${report_dir}/replay-1.stdout.txt" "${report_dir}/replay-2.stdout.txt" +replay_one_sha="$(sha256sum "${report_dir}/replay-1.stdout.txt" | awk '{print $1}')" +replay_two_sha="$(sha256sum "${report_dir}/replay-2.stdout.txt" | awk '{print $1}')" +test "${replay_one_sha}" = "${replay_two_sha}" + +set +e +( + cd "${ltp_dir}" + LTP_BUILD="${ltp_sha}" pnpm exec ts-node tools/ltp-inspect/inspect.ts explain --color never --input "${trace_path}" --at step-008 +) > "${report_dir}/explain-step-008.stdout.txt" 2> "${report_dir}/explain-step-008.stderr.txt" +explain_code=$? +set -e +printf '%s\n' "${explain_code}" > "${report_dir}/explain-step-008.exit-code.txt" +if [ "${explain_code}" -ne 0 ]; then + echo "explain failed with exit ${explain_code}" >&2 + dump_failure + exit "${explain_code}" +fi + +python3 - "${report_dir}" "${replay_one_sha}" "${replay_two_sha}" "${ltp_sha}" "${registry_sha}" <<'PY' +import json +import sys +from pathlib import Path + +report_dir = Path(sys.argv[1]) +payload = { + "schema_version": "liminalqa-ltp-replay-comparison-v1", + "byte_identical": True, + "replay_1_sha256": sys.argv[2], + "replay_2_sha256": sys.argv[3], + "ltp_inspector_sha": sys.argv[4], + "critical_actions_registry_sha256": sys.argv[5], + "inspector_exit_code": int((report_dir / "inspector.exit-code.txt").read_text().strip()), + "replay_1_exit_code": int((report_dir / "replay-1.exit-code.txt").read_text().strip()), + "replay_2_exit_code": int((report_dir / "replay-2.exit-code.txt").read_text().strip()), +} +(report_dir / "replay-comparison.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" +) +PY + +sha256sum "${trace_path}" > "${report_dir}/trace.sha256" +sha256sum "${report_dir}/inspector-report.json" > "${report_dir}/inspector-report.sha256" diff --git a/scripts/write_exact_head_manifest.py b/scripts/write_exact_head_manifest.py new file mode 100644 index 00000000..acc70135 --- /dev/null +++ b/scripts/write_exact_head_manifest.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Write exact-head evidence manifests and post-upload artifact receipts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SHA40_RE = re.compile(r"^[0-9a-f]{40}$") +SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-f]{64}$") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def require_sha40(value: str, field: str) -> str: + if not SHA40_RE.fullmatch(value): + raise ValueError(f"{field} must be a lowercase 40-character SHA") + return value + + +def require_sha256(value: str, field: str) -> str: + if not SHA256_RE.fullmatch(value): + raise ValueError(f"{field} must be a SHA-256 digest") + return value.removeprefix("sha256:") + + +def read_rfc3339(value: str, field: str) -> str: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{field} must be RFC3339") from exc + if parsed.tzinfo is None: + raise ValueError(f"{field} must include a timezone") + return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def collect_files(output_dir: Path, excluded: set[Path]) -> list[dict[str, Any]]: + files: list[dict[str, Any]] = [] + for path in sorted(output_dir.rglob("*")): + if not path.is_file() or path.resolve() in excluded: + continue + files.append( + { + "path": path.relative_to(output_dir).as_posix(), + "size_bytes": path.stat().st_size, + "sha256": sha256_file(path), + } + ) + return files + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def manifest_command(args: argparse.Namespace) -> None: + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + manifest_path = output_dir / args.manifest_name + + expected_sha = require_sha40(args.expected_sha, "expected_sha") + initial_sha = require_sha40(args.initial_sha, "initial_sha") + final_sha = require_sha40(args.final_sha, "final_sha") + workflow_sha = require_sha40(args.workflow_sha, "workflow_sha") + if not expected_sha == initial_sha == final_sha: + raise ValueError("expected_sha, initial_sha, and final_sha must match") + + started_at = read_rfc3339(args.started_at, "started_at") + completed_at = read_rfc3339(args.completed_at, "completed_at") + if completed_at < started_at: + raise ValueError("completed_at must not precede started_at") + + files = collect_files(output_dir, {manifest_path.resolve()}) + payload = { + "schema_version": "liminalqa-exact-head-evidence-manifest-v1", + "audit": { + "name": args.audit_name, + "mode": "advisory-read-only", + "target": args.target, + "execution_status": args.execution_status, + }, + "source_identity": { + "repository": args.repository, + "expected_sha": expected_sha, + "initial_sha": initial_sha, + "final_sha": final_sha, + "workflow_sha": workflow_sha, + "head_stable": True, + "initial_worktree_clean": True, + "final_worktree_clean": True, + }, + "run_identity": { + "event_name": args.event_name, + "git_ref": args.git_ref, + "head_ref": args.head_ref or None, + "workflow_ref": args.workflow_ref, + "run_id": str(args.run_id), + "run_attempt": str(args.run_attempt), + "artifact_name": args.artifact_name, + }, + "collection": { + "started_at": started_at, + "completed_at": completed_at, + "file_count": len(files), + }, + "files": files, + "authority": { + "allowed": ["public passive observation", "read-only evidence capture"], + "prohibited": [ + "authentication", + "form submission", + "portfolio access", + "order entry", + "financial operation", + "fuzzing", + "load testing", + "deployment", + ], + }, + } + write_json(manifest_path, payload) + + +def receipt_command(args: argparse.Namespace) -> None: + manifest_path = Path(args.manifest).resolve() + if not manifest_path.is_file(): + raise FileNotFoundError(f"manifest not found: {manifest_path}") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + expected_name = manifest["run_identity"]["artifact_name"] + if args.artifact_name != expected_name: + raise ValueError("artifact_name does not match manifest") + if str(args.run_id) != str(manifest["run_identity"]["run_id"]): + raise ValueError("run_id does not match manifest") + if str(args.run_attempt) != str(manifest["run_identity"]["run_attempt"]): + raise ValueError("run_attempt does not match manifest") + + receipt = { + "schema_version": "liminalqa-artifact-receipt-v1", + "manifest": { + "path": manifest_path.name, + "sha256": sha256_file(manifest_path), + }, + "artifact": { + "name": args.artifact_name, + "id": str(args.artifact_id), + "url": args.artifact_url, + "sha256": require_sha256(args.artifact_digest, "artifact_digest"), + }, + "run_identity": { + "run_id": str(args.run_id), + "run_attempt": str(args.run_attempt), + }, + } + write_json(Path(args.output).resolve(), receipt) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + manifest = subparsers.add_parser("manifest") + manifest.add_argument("--output-dir", required=True) + manifest.add_argument("--manifest-name", default="manifest.json") + manifest.add_argument("--audit-name", required=True) + manifest.add_argument("--target", required=True) + manifest.add_argument("--repository", required=True) + manifest.add_argument("--expected-sha", required=True) + manifest.add_argument("--initial-sha", required=True) + manifest.add_argument("--final-sha", required=True) + manifest.add_argument("--workflow-sha", required=True) + manifest.add_argument("--event-name", required=True) + manifest.add_argument("--git-ref", required=True) + manifest.add_argument("--head-ref", default="") + manifest.add_argument("--workflow-ref", required=True) + manifest.add_argument("--run-id", required=True) + manifest.add_argument("--run-attempt", required=True) + manifest.add_argument("--artifact-name", required=True) + manifest.add_argument("--started-at", required=True) + manifest.add_argument("--completed-at", required=True) + manifest.add_argument("--execution-status", required=True) + manifest.set_defaults(func=manifest_command) + + receipt = subparsers.add_parser("receipt") + receipt.add_argument("--manifest", required=True) + receipt.add_argument("--output", required=True) + receipt.add_argument("--artifact-name", required=True) + receipt.add_argument("--artifact-id", required=True) + receipt.add_argument("--artifact-url", required=True) + receipt.add_argument("--artifact-digest", required=True) + receipt.add_argument("--run-id", required=True) + receipt.add_argument("--run-attempt", required=True) + receipt.set_defaults(func=receipt_command) + + return parser + + +def main() -> int: + args = build_parser().parse_args() + args.func(args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write_ltp_audit_trace.py b/scripts/write_ltp_audit_trace.py new file mode 100755 index 00000000..6afaaa50 --- /dev/null +++ b/scripts/write_ltp_audit_trace.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Create/verify a deterministic, read-only LTP JSONL audit trace.""" +from __future__ import annotations +import argparse, hashlib, json, math, re +from datetime import datetime +from pathlib import Path +from typing import Any, Iterable + +ZERO="0"*64; SHA=re.compile(r"^[0-9a-f]{64}$"); COMMIT=re.compile(r"^[0-9a-f]{40}$") +class TraceContractError(ValueError): pass + +def _js_numbers(v:Any)->Any: + if isinstance(v,float): + if not math.isfinite(v):raise TraceContractError("non-finite number is not canonical JSON") + return int(v) if v.is_integer() else v + if isinstance(v,list):return [_js_numbers(x) for x in v] + if isinstance(v,dict):return {k:_js_numbers(x) for k,x in v.items()} + return v +def canon(v:Any)->bytes:return json.dumps(_js_numbers(v),sort_keys=True,separators=(",",":"),ensure_ascii=False).encode() +def fsha(p:Path)->str: + h=hashlib.sha256() + with p.open("rb") as f: + for b in iter(lambda:f.read(1<<20),b""):h.update(b) + return h.hexdigest() +def ts(v:str)->str: + try:d=datetime.fromisoformat(v.replace("Z","+00:00")) + except ValueError as e:raise TraceContractError(f"invalid RFC3339 timestamp: {v}") from e + if d.tzinfo is None:raise TraceContractError(f"timestamp lacks timezone: {v}") + return v +def load_registry(p:Path)->tuple[set[str],str]: + try:x=json.loads(p.read_text()) + except Exception as e:raise TraceContractError(f"invalid critical-action registry: {p}") from e + a=x.get("actions") if isinstance(x,dict) else None + if not isinstance(a,dict) or not a:raise TraceContractError("registry actions missing") + return set(a),fsha(p) +def make_frame(fid:str,timestamp:str,kind:str,payload:dict[str,Any],continuity:str|None)->dict[str,Any]: + f={"v":"0.1","id":fid,"ts":timestamp,"type":kind,"payload":payload} + if continuity:f["continuity_token"]=continuity + return f +def build_entries(frames:Iterable[tuple[str,dict[str,Any]]],session:str)->list[dict[str,Any]]: + out=[];prev=ZERO + for i,(direction,frame) in enumerate(frames): + digest=hashlib.sha256(prev.encode()+canon(frame)).hexdigest() + out.append({"i":i,"ts":frame["ts"],"direction":direction,"session_id":session,"frame":frame,"prev_hash":prev,"hash":digest});prev=digest + return out +def parse_jsonl(p:Path)->list[dict[str,Any]]: + if not p.is_file():raise TraceContractError(f"trace not found: {p}") + out=[] + for n,line in enumerate(p.read_text(encoding="utf-8-sig").splitlines(),1): + if not line.strip():continue + try:v=json.loads(line) + except json.JSONDecodeError as e:raise TraceContractError(f"invalid JSONL line {n}: {e.msg}") from e + if not isinstance(v,dict):raise TraceContractError(f"line {n} is not object") + out.append(v) + if not out:raise TraceContractError("trace is empty") + return out +def verify_entries(entries:list[dict[str,Any]],critical:set[str])->dict[str,Any]: + session=None;prev=ZERO;ids=set();cts=set();identity=None;routes=[] + for i,e in enumerate(entries): + if e.get("i")!=i:raise TraceContractError(f"entry index mismatch at position {i}") + s=e.get("session_id") + if not isinstance(s,str) or not s:raise TraceContractError(f"missing session_id at position {i}") + if session is None:session=s + elif s!=session:raise TraceContractError(f"session identity changed at position {i}") + f=e.get("frame") + if not isinstance(f,dict):raise TraceContractError(f"missing frame at position {i}") + if f.get("v")!="0.1":raise TraceContractError(f"unsupported frame version at position {i}") + fid=f.get("id") + if not isinstance(fid,str) or not fid:raise TraceContractError(f"missing frame id at position {i}") + if fid in ids:raise TraceContractError(f"duplicate frame id: {fid}") + ids.add(fid);ts(str(f.get("ts",""))) + if not isinstance(f.get("type"),str):raise TraceContractError(f"missing frame type at position {i}") + p=f.get("payload",{}) + if not isinstance(p,dict):raise TraceContractError(f"payload not object at position {i}") + ct=f.get("continuity_token") + if ct is not None: + if not isinstance(ct,str) or not ct:raise TraceContractError(f"invalid continuity token at position {i}") + cts.add(ct) + if f["type"]=="orientation" and isinstance(p.get("identity"),str): + if identity is None:identity=p["identity"] + elif identity!=p["identity"]:raise TraceContractError("orientation identity changed") + if e.get("prev_hash")!=prev:raise TraceContractError(f"broken previous-hash binding at position {i}") + cur=e.get("hash") + if not isinstance(cur,str) or not SHA.fullmatch(cur):raise TraceContractError(f"invalid event hash at position {i}") + calc=hashlib.sha256(prev.encode()+canon(f)).hexdigest() + if cur!=calc:raise TraceContractError(f"event hash mismatch at position {i}") + prev=cur + if f["type"]=="route_response": + routes.append(p);decision=str(p.get("decision","")).upper();allow=p.get("admissible") is True + if decision in {"BLOCK","DENY","HOLD","FREEZE"} and allow:raise TraceContractError(f"non-ALLOW decision marked admissible at position {i}") + if p.get("context")=="WEB" and p.get("targetState") in critical and allow:raise TraceContractError(f"critical WEB-direct action at position {i}") + if len(cts)>1:raise TraceContractError("continuity token changed") + if not identity:raise TraceContractError("identity binding missing") + return {"valid":True,"frames":len(entries),"session_id":session,"identity":identity,"hash_root":prev,"continuity_token":next(iter(cts),None),"route_decisions":len(routes)} +def inv(root:Path)->list[dict[str,Any]]: + out=[] + for p in sorted(root.rglob("*")): + if p.is_file(): + r=p.relative_to(root).as_posix() + if not r.startswith("ltp/") and r not in {"manifest.json","artifact-receipt.json"}:out.append({"path":r,"size_bytes":p.stat().st_size,"sha256":fsha(p)}) + return out +def writej(p:Path,v:Any):p.parent.mkdir(parents=True,exist_ok=True);p.write_text(json.dumps(v,indent=2,sort_keys=True,ensure_ascii=False)+"\n") +def build(a:argparse.Namespace)->int: + for name in ("expected_sha","initial_sha","workflow_sha","ltp_sha"): + if not COMMIT.fullmatch(getattr(a,name)):raise TraceContractError(f"{name} must be 40-char SHA") + if a.expected_sha!=a.initial_sha:raise TraceContractError("initial SHA differs from expected SHA") + start=ts(a.started_at);root=Path(a.output_dir).resolve();critical,rsha=load_registry(Path(a.critical_actions_registry).resolve());files=inv(root) + invroot=hashlib.sha256(canon(files)).hexdigest();session=f"tradernet-{a.run_id}-{a.run_attempt}";ct="ct-"+hashlib.sha256(f"{a.repository}:{a.expected_sha}:{a.run_id}:{a.run_attempt}".encode()).hexdigest()[:24];identity=f"{a.repository}@{a.expected_sha}" + constraints={"public_page_only":True,"read_only":True,"no_authentication":True,"no_form_submission":True,"no_financial_operation":True,"no_external_message":True,"no_deploy":True,"no_protected_effect":True} + frames=[ + ("out",make_frame("step-001",start,"hello",{"agent":"liminalqa-tradernet-auditor","repository":a.repository,"expected_sha":a.expected_sha,"workflow_sha":a.workflow_sha,"run_id":str(a.run_id),"run_attempt":str(a.run_attempt),"artifact_name":a.artifact_name,"ltp_inspector_sha":a.ltp_sha,"critical_actions_registry_sha256":rsha},None)), + ("out",make_frame("step-002",start,"orientation",{"identity":identity,"status":"healthy","drift":0.0,"constraints":constraints},ct)), + ("out",make_frame("step-003",start,"focus_snapshot",{"identity":identity,"drift":0.0,"focus_momentum":1.0,"rationale":"bounded evidence capture"},ct)), + ("in",make_frame("step-004",start,"route_request",{"goal":"record bounded public audit evidence","source_context":"CI","target":a.target,"repository":a.repository,"expected_sha":a.expected_sha,"run_id":str(a.run_id),"run_attempt":str(a.run_attempt),"constraints":constraints},ct)), + ("out",make_frame("step-005",start,"route_response",{"context":"CI","targetState":"capture_public_evidence","admissible":True,"decision":"EXECUTE","capabilities":[],"branches":[{"id":"bounded-public-capture","confidence":1.0,"status":"admissible","reason":"read-only public scope and exact-head identity verified"}]},ct)), + ("out",make_frame("step-006",start,"observation",{"capture_status":a.capture_status,"evidence_file_count":len(files),"evidence_inventory_sha256":invroot,"evidence_files":files},ct)), + ("in",make_frame("step-007",start,"route_request",{"goal":"preserve immutable audit evidence","source_context":"CI","constraints":constraints},ct)), + ("out",make_frame("step-008",start,"route_response",{"context":"CI","targetState":"write_audit_artifact","admissible":True,"decision":"EXECUTE","capabilities":[],"branches":[{"id":"immutable-artifact","confidence":1.0,"status":"admissible","reason":"output remains inside declared CI artifact boundary"}]},ct)), + ("out",make_frame("step-009",start,"orientation",{"identity":identity,"status":"healthy","drift":0.0,"focus_momentum":1.0,"constraints":constraints},ct))] + entries=build_entries(frames,session);d=root/"ltp";d.mkdir(parents=True,exist_ok=True);trace=d/"trace.jsonl";trace.write_text("\n".join(json.dumps(e,separators=(",",":"),ensure_ascii=False) for e in entries)+"\n") + result=verify_entries(entries,critical);result.update({"trace_sha256":fsha(trace),"ltp_inspector_sha":a.ltp_sha,"critical_actions_registry_sha256":rsha,"evidence_inventory_sha256":invroot});writej(d/"local-verification.json",result);return 0 +def verify(a:argparse.Namespace)->int: + critical,rsha=load_registry(Path(a.critical_actions_registry).resolve());p=Path(a.trace).resolve();r=verify_entries(parse_jsonl(p),critical);r.update({"trace_sha256":fsha(p),"critical_actions_registry_sha256":rsha});writej(Path(a.output).resolve(),r) if a.output else print(json.dumps(r,sort_keys=True));return 0 +def parser()->argparse.ArgumentParser: + p=argparse.ArgumentParser();s=p.add_subparsers(dest="cmd",required=True);b=s.add_parser("build") + for n in ("output_dir","audit_name","target","repository","expected_sha","initial_sha","workflow_sha","run_id","run_attempt","started_at","capture_status","artifact_name","ltp_sha","critical_actions_registry"):b.add_argument("--"+n.replace("_","-"),required=True) + b.set_defaults(fn=build);v=s.add_parser("verify");v.add_argument("--trace",required=True);v.add_argument("--critical-actions-registry",required=True);v.add_argument("--output");v.set_defaults(fn=verify);return p +def main()->int: + try: + a=parser().parse_args();return int(a.fn(a)) + except TraceContractError as e:print(f"TRACE CONTRACT ERROR: {e}");return 2 +if __name__=="__main__":raise SystemExit(main()) diff --git a/tests/test_write_exact_head_manifest.py b/tests/test_write_exact_head_manifest.py new file mode 100644 index 00000000..95471f70 --- /dev/null +++ b/tests/test_write_exact_head_manifest.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import argparse +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "write_exact_head_manifest.py" +spec = importlib.util.spec_from_file_location("write_exact_head_manifest", MODULE_PATH) +module = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(module) + +SHA = "a" * 40 +WORKFLOW_SHA = "b" * 40 + + +class ExactHeadManifestTests(unittest.TestCase): + def test_manifest_hashes_files_and_binds_identity(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output_dir = Path(tmp) + (output_dir / "result.json").write_text('{"ok":true}\n', encoding="utf-8") + args = argparse.Namespace( + output_dir=str(output_dir), + manifest_name="manifest.json", + audit_name="test audit", + target="https://example.test/", + repository="owner/repo", + expected_sha=SHA, + initial_sha=SHA, + final_sha=SHA, + workflow_sha=WORKFLOW_SHA, + event_name="pull_request", + git_ref="refs/pull/1/merge", + head_ref="agent/test", + workflow_ref="owner/repo/.github/workflows/test.yml@refs/pull/1/merge", + run_id="123", + run_attempt="2", + artifact_name="evidence-123-2", + started_at="2026-07-27T00:00:00Z", + completed_at="2026-07-27T00:00:01Z", + execution_status="success", + ) + module.manifest_command(args) + manifest = json.loads((output_dir / "manifest.json").read_text(encoding="utf-8")) + self.assertTrue(manifest["source_identity"]["head_stable"]) + self.assertEqual(manifest["run_identity"]["run_attempt"], "2") + self.assertEqual([item["path"] for item in manifest["files"]], ["result.json"]) + self.assertEqual( + manifest["files"][0]["sha256"], + module.sha256_file(output_dir / "result.json"), + ) + + def test_manifest_rejects_head_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + args = argparse.Namespace( + output_dir=tmp, + manifest_name="manifest.json", + audit_name="test", + target="https://example.test/", + repository="owner/repo", + expected_sha=SHA, + initial_sha=SHA, + final_sha="c" * 40, + workflow_sha=WORKFLOW_SHA, + event_name="push", + git_ref="refs/heads/main", + head_ref="", + workflow_ref="workflow", + run_id="1", + run_attempt="1", + artifact_name="evidence-1-1", + started_at="2026-07-27T00:00:00Z", + completed_at="2026-07-27T00:00:01Z", + execution_status="success", + ) + with self.assertRaisesRegex(ValueError, "must match"): + module.manifest_command(args) + + def test_receipt_binds_uploaded_artifact_to_manifest(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "result.txt").write_text("evidence\n", encoding="utf-8") + manifest_args = argparse.Namespace( + output_dir=str(root), + manifest_name="manifest.json", + audit_name="test", + target="https://example.test/", + repository="owner/repo", + expected_sha=SHA, + initial_sha=SHA, + final_sha=SHA, + workflow_sha=WORKFLOW_SHA, + event_name="push", + git_ref="refs/heads/main", + head_ref="", + workflow_ref="workflow", + run_id="7", + run_attempt="3", + artifact_name="evidence-7-3", + started_at="2026-07-27T00:00:00Z", + completed_at="2026-07-27T00:00:01Z", + execution_status="success", + ) + module.manifest_command(manifest_args) + receipt_path = root / "receipt" / "artifact-receipt.json" + receipt_args = argparse.Namespace( + manifest=str(root / "manifest.json"), + output=str(receipt_path), + artifact_name="evidence-7-3", + artifact_id="999", + artifact_url="https://example.test/artifacts/999", + artifact_digest="sha256:" + "d" * 64, + run_id="7", + run_attempt="3", + ) + module.receipt_command(receipt_args) + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + self.assertEqual(receipt["artifact"]["id"], "999") + self.assertEqual(receipt["artifact"]["sha256"], "d" * 64) + self.assertEqual( + receipt["manifest"]["sha256"], + module.sha256_file(root / "manifest.json"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_write_ltp_audit_trace.py b/tests/test_write_ltp_audit_trace.py new file mode 100644 index 00000000..db29fcce --- /dev/null +++ b/tests/test_write_ltp_audit_trace.py @@ -0,0 +1,95 @@ +import copy +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "write_ltp_audit_trace.py" +spec = importlib.util.spec_from_file_location("write_ltp_audit_trace", MODULE_PATH) +module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(module) + + +class TraceTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.registry = self.root / "registry.json" + self.registry.write_text(json.dumps({"actions": {"send_message": {}, "transfer_money": {}}}), encoding="utf-8") + self.critical, _ = module.load_registry(self.registry) + timestamp = "2026-07-27T00:00:00.000Z" + continuity = "ct-test" + frames = [ + ("out", module.make_frame("step-1", timestamp, "hello", {"agent": "test"}, None)), + ("out", module.make_frame("step-2", timestamp, "orientation", {"identity": "repo@sha", "status": "healthy"}, continuity)), + ("out", module.make_frame("step-3", timestamp, "focus_snapshot", {"drift": 0.0}, continuity)), + ("in", module.make_frame("step-4", timestamp, "route_request", {"goal": "observe"}, continuity)), + ("out", module.make_frame("step-5", timestamp, "route_response", {"context": "CI", "targetState": "capture_public_evidence", "admissible": True, "decision": "EXECUTE", "branches": [{"id": "a", "confidence": 1.0, "status": "admissible"}]}, continuity)), + ] + self.entries = module.build_entries(frames, "session-1") + + def tearDown(self): + self.temp.cleanup() + + def test_js_numeric_canonicalization(self): + self.assertEqual(module.canon({"x": 0.0, "y": [1.0, 1.5]}), b'{"x":0,"y":[1,1.5]}') + + def test_clean_trace_passes(self): + result = module.verify_entries(self.entries, self.critical) + self.assertTrue(result["valid"]) + self.assertEqual(result["frames"], 5) + + def test_tampered_frame_fails(self): + entries = copy.deepcopy(self.entries) + entries[3]["frame"]["payload"]["goal"] = "tampered" + with self.assertRaisesRegex(module.TraceContractError, "event hash mismatch"): + module.verify_entries(entries, self.critical) + + def test_reordered_entries_fail(self): + entries = copy.deepcopy(self.entries) + entries[2], entries[3] = entries[3], entries[2] + with self.assertRaises(module.TraceContractError): + module.verify_entries(entries, self.critical) + + def test_duplicate_frame_id_fails(self): + entries = copy.deepcopy(self.entries) + entries[2]["frame"]["id"] = entries[1]["frame"]["id"] + entries = module.build_entries([(entry["direction"], entry["frame"]) for entry in entries], "session-1") + with self.assertRaisesRegex(module.TraceContractError, "duplicate frame id"): + module.verify_entries(entries, self.critical) + + def test_session_identity_change_fails(self): + entries = copy.deepcopy(self.entries) + entries[-1]["session_id"] = "other-session" + with self.assertRaisesRegex(module.TraceContractError, "session identity changed"): + module.verify_entries(entries, self.critical) + + def test_unsupported_version_fails(self): + entries = copy.deepcopy(self.entries) + entries[0]["frame"]["v"] = "9.9" + entries = module.build_entries([(entry["direction"], entry["frame"]) for entry in entries], "session-1") + with self.assertRaisesRegex(module.TraceContractError, "unsupported frame version"): + module.verify_entries(entries, self.critical) + + def test_malformed_jsonl_fails(self): + path = self.root / "bad.jsonl" + path.write_text('{"i":0}\n{"broken"\n', encoding="utf-8") + with self.assertRaisesRegex(module.TraceContractError, "invalid JSONL"): + module.parse_jsonl(path) + + def test_web_direct_critical_action_fails(self): + timestamp = "2026-07-27T00:00:00.000Z" + continuity = "ct-test" + frames = [ + ("out", module.make_frame("step-1", timestamp, "orientation", {"identity": "repo@sha"}, continuity)), + ("out", module.make_frame("step-2", timestamp, "route_response", {"context": "WEB", "targetState": "send_message", "admissible": True, "decision": "EXECUTE", "branches": [{"id": "unsafe", "confidence": 1.0, "status": "admissible"}]}, continuity)), + ] + entries = module.build_entries(frames, "session-1") + with self.assertRaisesRegex(module.TraceContractError, "critical WEB-direct action"): + module.verify_entries(entries, self.critical) + + +if __name__ == "__main__": + unittest.main()