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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1125,3 +1125,13 @@ artifact's workflow/run/head provenance and investigate the cited code. Encrypti
protects confidentiality and ciphertext integrity; it does not authenticate a
reviewer's identity or make a finding correct. Do not paste decrypted content into
public comments, logs, or a public validator PR.

### Opt-in assessment completion and encrypted attempt diagnostics

`require-complete-assessment: 'true'` requires exactly one `- **Assessment**: COMPLETE` line in the final report's Validation Outcome and an OpenCode terminal finish reason of `stop`. Missing or INCOMPLETE assessments consume the existing whole-review retry budget, then the configured fallback, under the same shared deadline. A completed FAIL, including zero critical issues, remains a completed blocking verdict. Defaults preserve existing consumers. This status verifies the execution contract, not the truth of claimed review coverage.

Separate `encrypted-attempt-recipient` and `encrypted-attempt-recipient-sha256` inputs explicitly authorize exporting each attempt's assembled prompt, provider JSON stdout and stderr. These can contain confidential source and tool results. Supply a trusted RSA public certificate and its SHA256 DER fingerprint, independently of PR-controlled content; keep its private key off-runner. No environment dump or provider configuration is collected. The action captures immediately after every provider invocation, before extraction and before retry files are overwritten. The bundle includes exact input hashes, attempt/model/exit status and allowlisted run/ref provenance. It encrypts using authenticated CMS AES-256-GCM and RSA-OAEP/SHA256, uploads ciphertext only with one-day retention, then removes its local ciphertext during unconditional cleanup. Capture failure blocks execution, with no plaintext fallback. Missing private final reports no longer prevent diagnostics when this separate option is enabled.

Public extraction metadata includes allowlisted finish reasons, bounded numeric token/cache/cost fields, and tool-state counts. Unknown text and malformed numeric fields become fixed unknown/null values. No commands, tool arguments, arbitrary errors or raw transcript fields enter these diagnostics. The diff and web-fetch policy now permits read-only Git scope verification and GitHub API GET requests for exact-head CI evidence without permitting general external downloads or GitHub mutations.

This candidate does not establish why a provider stopped early. Use captured evidence before changing context limits or models. Required grounding, existing model configuration, strict terminal extraction, publication policy and failed-verdict gates remain in force. Consumer adoption, stable release and shared alias promotion are separate gates.
36 changes: 34 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ inputs:
description: 'Trusted SHA256 of the recipient certificate DER (64 hex characters). Required with encrypted-report-recipient; never derive from PR-controlled input.'
required: false
default: ''
encrypted-attempt-recipient:
description: 'Explicit opt-in RSA PEM recipient for encrypted per-attempt prompt/stdout/stderr capture. These may contain confidential source/tool data. Keep the private key off-runner; authorize this separate export.'
required: false
default: ''
encrypted-attempt-recipient-sha256:
description: 'Trusted SHA256 DER fingerprint for the attempt recipient; required together. Never derive from untrusted PR input.'
required: false
default: ''
require-complete-assessment:
description: 'Opt in to explicit COMPLETE assessment and normal OpenCode stop reason. Missing/incomplete assessment retries within the shared deadline; completed FAIL remains blocking.'
required: false
default: 'false'
max-retries:
description: 'Maximum retry attempts for retryable provider failures or incomplete final reports'
required: false
Expand Down Expand Up @@ -174,6 +186,7 @@ runs:
VALIDATOR_COMMENT_MODE: ${{ inputs.comment-mode }}
VALIDATOR_MERGE_PROMPT: ${{ inputs.merge-custom-prompt }}
VALIDATOR_ALLOW_WEB_FETCH: ${{ inputs.allow-web-fetch }}
VALIDATOR_COMPLETE_ASSESSMENT: ${{ inputs.require-complete-assessment }}
VALIDATOR_MAX_RETRIES: ${{ inputs.max-retries }}
VALIDATOR_TIMEOUT: ${{ inputs.timeout-minutes }}
VALIDATOR_PROVIDER: ${{ inputs.provider }}
Expand Down Expand Up @@ -245,6 +258,11 @@ runs:
exit 1
fi

if [[ ! "$VALIDATOR_COMPLETE_ASSESSMENT" =~ ^(true|false)$ ]]; then
echo "::error::Invalid require-complete-assessment. Must be true or false"
exit 1
fi

# Validate provider
if [[ ! "$VALIDATOR_PROVIDER" =~ ^(opencode|gemini)$ ]]; then
echo "::error::Invalid provider. Must be: opencode or gemini"
Expand Down Expand Up @@ -491,6 +509,9 @@ runs:
OPENCODE_MODEL: ${{ inputs.opencode-model }}
FALLBACK_OPENCODE_PROVIDER: ${{ inputs.fallback-opencode-provider }}
FALLBACK_OPENCODE_MODEL: ${{ inputs.fallback-opencode-model }}
ENCRYPTED_ATTEMPT_RECIPIENT: ${{ inputs.encrypted-attempt-recipient }}
ENCRYPTED_ATTEMPT_RECIPIENT_SHA256: ${{ inputs.encrypted-attempt-recipient-sha256 }}
REQUIRE_COMPLETE_ASSESSMENT: ${{ inputs.require-complete-assessment }}
MAX_RETRIES: ${{ inputs.max-retries }}
VALIDATION_TIMEOUT_MINUTES: ${{ inputs.timeout-minutes }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
Expand All @@ -514,6 +535,15 @@ runs:
run: |
${{ github.action_path }}/scripts/validate.sh

- name: Upload Encrypted Attempt Diagnostics
if: always() && steps.validate.outputs.attempt_diagnostics_ready == 'true' && steps.publication.outputs.report_path != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.set-artifact-name.outputs.artifact_name }}-attempts-encrypted
path: ${{ steps.publication.outputs.report_path }}.attempts/*.cms
retention-days: 1
if-no-files-found: error

- name: Upload Encrypted Final Report
if: always() && steps.validate.outputs.encrypted_report_ready == 'true' && steps.publication.outputs.report_path != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down Expand Up @@ -614,14 +644,16 @@ runs:
# Only this invocation's known file/directory, never a broad tmp cleanup.
if [ -n "$REPORT_PATH" ]; then
rm -f -- "$REPORT_PATH" "${REPORT_PATH}.cms"
rm -f -- "${REPORT_PATH}.attempts/"*.cms
rmdir -- "${REPORT_PATH}.attempts" 2>/dev/null || true
rmdir -- "$(dirname "$REPORT_PATH")" 2>/dev/null || true
fi
echo "✅ Temporary files cleaned up"

- name: Fail on Critical Violations
- name: Fail on Unsuccessful Validation
if: inputs.fail-on-critical == 'true' && steps.validate.outputs.validation_passed == 'false'
shell: bash
run: |
echo "::error::❌ Critical violations found in PR validation"
echo "::error::Validation failed or assessment did not complete"
echo "Critical issues: ${{ steps.validate.outputs.critical_issues }}"
exit 1
66 changes: 66 additions & 0 deletions scripts/capture-provider-attempt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Opt-in action-owned encrypted attempt capture. No raw diagnostics on stdout."""
import argparse
import base64
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import stat
import subprocess
import sys

SPEC = importlib.util.spec_from_file_location(
"encryption", Path(__file__).with_name("encrypt-validation-report.py"))
ENCRYPTION = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(ENCRYPTION)
MAX_BYTES = 16 * 1024 * 1024


def read_input(path):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as handle:
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
raise ValueError("not_regular")
data = handle.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise ValueError("too_large")
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data),
"base64": base64.b64encode(data).decode("ascii")}


def capture(args):
if not 1 <= args.attempt <= 100 or not 0 <= args.exit_code <= 255:
raise ValueError("invalid_attempt")
bundle = {
"schema": 1, "attempt": args.attempt, "provider": args.provider,
"model": args.model, "exit_code": args.exit_code,
"provenance": {key: os.environ.get(key, "") for key in (
"GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", "GITHUB_JOB",
"GITHUB_SHA", "BASE_BRANCH", "HEAD_BRANCH")},
"inputs": {name: read_input(getattr(args, name)) for name in ("prompt", "stdout", "stderr")},
}
# Deliberately never collect environment dumps or provider credential configs.
ENCRYPTION.encrypt_payload(json.dumps(bundle, separators=(",", ":")).encode(), args.output,
os.environ.get("ENCRYPTED_ATTEMPT_RECIPIENT", ""),
os.environ.get("ENCRYPTED_ATTEMPT_RECIPIENT_SHA256", ""))


def main():
parser = ENCRYPTION.SafeParser()
for name in ("prompt", "stdout", "stderr", "output", "model"):
parser.add_argument("--" + name, required=True)
parser.add_argument("--provider", choices=("opencode", "gemini"), required=True)
parser.add_argument("--attempt", type=int, required=True)
parser.add_argument("--exit-code", type=int, required=True)
try:
capture(parser.parse_args())
except (OSError, ValueError, OverflowError, UnicodeError, subprocess.SubprocessError):
print("Encrypted attempt capture failed; no plaintext fallback.", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 10 additions & 6 deletions scripts/encrypt-validation-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,14 @@ def openssl(*args, data=None):
).stdout


def encrypt(report, output):
pem = os.environ.get("ENCRYPTED_REPORT_RECIPIENT", "")
fingerprint = os.environ.get("ENCRYPTED_REPORT_RECIPIENT_SHA256", "")
def encrypt_payload(data, output, pem, fingerprint):
if not re.fullmatch(r"[a-fA-F0-9]{64}", fingerprint):
raise ValueError("invalid_fingerprint")
if len(pem) > 16384 or not re.fullmatch(
r"\s*-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\r\n]+"
r"-----END CERTIFICATE-----\s*", pem
):
raise ValueError("invalid_certificate")
text = PUBLICATION.read_private(report)
PUBLICATION.PARSER.parse_report(text)
if not openssl("version").startswith(b"OpenSSL 3."):
raise ValueError("openssl_3_required")
with tempfile.TemporaryDirectory(prefix="validator-recipient-") as directory:
Expand Down Expand Up @@ -66,7 +62,7 @@ def encrypt(report, output):
"cms", "-encrypt", "-binary", "-aes-256-gcm", "-outform", "DER",
"-recip", str(certificate), "-keyopt", "rsa_padding_mode:oaep",
"-keyopt", "rsa_oaep_md:sha256", "-keyopt", "rsa_mgf1_md:sha256",
data=text.encode("utf-8"),
data=data,
)
if not ciphertext:
raise ValueError("empty_ciphertext")
Expand All @@ -84,6 +80,14 @@ def encrypt(report, output):
temporary.unlink(missing_ok=True)


def encrypt(report, output):
text = PUBLICATION.read_private(report)
PUBLICATION.PARSER.parse_report(text)
encrypt_payload(text.encode("utf-8"), output,
os.environ.get("ENCRYPTED_REPORT_RECIPIENT", ""),
os.environ.get("ENCRYPTED_REPORT_RECIPIENT_SHA256", ""))


class SafeParser(argparse.ArgumentParser):
def error(self, message):
self.exit(2, "Encrypted report rejected: invalid arguments.\n")
Expand Down
51 changes: 47 additions & 4 deletions scripts/extract-provider-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import argparse
from collections import Counter
import json
import math
import re
import sys

Expand Down Expand Up @@ -45,7 +46,44 @@ def _response_metadata(provider, response):
}


def _opencode(text):
FINISH_REASONS = {"stop", "tool-calls", "length", "content-filter", "error", "other", "unknown"}


def _number(value, maximum):
if type(value) is int and 0 <= value <= maximum:
return value
if type(value) is float and math.isfinite(value) and 0 <= value <= maximum:
return value
return None


def _step_metadata(events):
steps = []
tools = Counter()
for event in events:
part = event.get("part")
if not isinstance(part, dict):
continue
if event.get("type") == "tool_use":
state = part.get("state")
status = state.get("status") if isinstance(state, dict) else None
tools[status if status in ("completed", "error", "running", "pending") else "unknown"] += 1
if event.get("type") != "step_finish":
continue
reason = part.get("reason")
item = {"reason": reason if isinstance(reason, str) and reason in FINISH_REASONS else "unknown"}
tokens = part.get("tokens")
if isinstance(tokens, dict):
item["tokens"] = {key: _number(tokens.get(key), 10**12) for key in ("total", "input", "output", "reasoning")}
cache = tokens.get("cache")
if isinstance(cache, dict):
item["cache"] = {key: _number(cache.get(key), 10**12) for key in ("read", "write")}
item["cost"] = _number(part.get("cost"), 10**6)
steps.append(item)
return {"steps": steps[-128:], "steps_omitted": max(0, len(steps) - 128), "tool_states": dict(tools)}


def _opencode(text, require_stop_reason=False):
events = []
counts = Counter()
message_ids = set()
Expand Down Expand Up @@ -95,6 +133,8 @@ def _opencode(text):
"terminal_event": _safe_event_type(events[-1].get("type")) if events else None,
})

diagnostics.update(_step_metadata(events))

if not events:
diagnostics["error_code"] = "empty_output"
raise ExtractionError("empty_output", "OpenCode output has no events", diagnostics)
Expand Down Expand Up @@ -124,6 +164,8 @@ def _opencode(text):
"OpenCode terminal step has an invalid part",
diagnostics,
)
if require_stop_reason and terminal_part.get("reason") != "stop":
raise ExtractionError("terminal_reason_not_stop", "OpenCode did not finish normally", diagnostics)
message_id = terminal_part.get("messageID")
if not isinstance(message_id, str) or not message_id:
diagnostics["error_code"] = "missing_terminal_message_id"
Expand Down Expand Up @@ -232,9 +274,9 @@ def _gemini(text):
return response, diagnostics


def extract_with_diagnostics(provider, text):
def extract_with_diagnostics(provider, text, require_stop_reason=False):
if provider == "opencode":
return _opencode(text)
return _opencode(text, require_stop_reason=require_stop_reason)
if provider == "gemini":
return _gemini(text)
raise ExtractionError("unsupported_provider", "unsupported provider", {"provider": provider})
Expand All @@ -256,11 +298,12 @@ def _log_diagnostics(diagnostics):
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--provider", choices=("opencode", "gemini"), required=True)
parser.add_argument("--require-stop-reason", action="store_true")
parser.add_argument("input")
args = parser.parse_args(argv)
try:
with open(args.input, encoding="utf-8") as handle:
result, diagnostics = extract_with_diagnostics(args.provider, handle.read())
result, diagnostics = extract_with_diagnostics(args.provider, handle.read(), require_stop_reason=args.require_stop_reason)
except OSError as exc:
_log_diagnostics({"provider": args.provider, "error_code": "input_read_failed"})
print(f"Could not extract provider response: {exc}", file=sys.stderr)
Expand Down
12 changes: 10 additions & 2 deletions scripts/parse-validation-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def normalize_report(text):
return normalized


def parse_report(text):
def parse_report(text, require_complete=False):
text = normalize_report(text)
if text.count(OUTCOME) != 1:
raise ValueError("report must contain exactly one validation outcome")
Expand All @@ -72,6 +72,13 @@ def parse_report(text):
critical_counts = CRITICAL_RE.findall(outcome)
if len(statuses) != 1 or len(critical_counts) != 1:
raise ValueError("validation outcome must contain one status and one critical count")
if require_complete:
# Count every field, including annotated or malformed values, before
# validating its value. A second contradictory field must not vanish.
assessment = re.findall(r"^[ \t]*[-*][ \t]+(?:\*\*)?Assessment(?:\*\*)?[ \t]*:([^\r\n]*)$", outcome, re.MULTILINE)
canonical = re.findall(r"^- \*\*Assessment\*\*:[ \t]*COMPLETE[ \t]*$", outcome, re.MULTILINE)
if len(assessment) != 1 or len(canonical) != 1:
raise ValueError("assessment_missing_or_incomplete")
status = statuses[0]
critical_issues = int(critical_counts[0])
if critical_issues > 1000:
Expand All @@ -90,6 +97,7 @@ def parse_report(text):
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--normalize", action="store_true", help="print normalized report Markdown")
parser.add_argument("--require-complete-assessment", action="store_true")
parser.add_argument("report")
args = parser.parse_args(argv)
try:
Expand All @@ -108,7 +116,7 @@ def main(argv=None):
return 0

try:
result = parse_report(text)
result = parse_report(text, require_complete=args.require_complete_assessment)
except ValueError as exc:
print(f"Invalid validation report: {exc}", file=sys.stderr)
return 1
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-action-dependencies.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ assert_count 1 \
assert_count 1 \
'^[[:space:]]*uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9\.0\.0$' \
'github-script v9.0.0 pin'
assert_count 2 \
assert_count 3 \
'^[[:space:]]*uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7\.0\.1$' \
'upload-artifact v7.0.1 pins'
assert_count 1 \
Expand Down
Loading
Loading