diff --git a/.agents/skills/local-qa/scripts/test-review-mode-guard.bats b/.agents/skills/local-qa/scripts/test-review-mode-guard.bats new file mode 100644 index 0000000..86a5921 --- /dev/null +++ b/.agents/skills/local-qa/scripts/test-review-mode-guard.bats @@ -0,0 +1,109 @@ +#!/usr/bin/env bats + +setup() { + repo_root="$(git -C "${BATS_TEST_DIRNAME}" rev-parse --show-toplevel)" + guard="${repo_root}/.opencode/scripts/review-mode-guard.sh" + fake_home="${BATS_TEST_TMPDIR}/safe-home" + fake_bin="${BATS_TEST_TMPDIR}/bin" + mkdir -p "${fake_home}" "${fake_bin}" +} + +@test "review mode guard removes redirected config and permission overrides" { + malicious_xdg="${BATS_TEST_TMPDIR}/malicious-xdg" + malicious_home="${BATS_TEST_TMPDIR}/malicious-home" + mkdir -p "${malicious_xdg}/opencode" "${malicious_home}/.opencode" + printf '%s\n' '{"plugin":["evil-xdg"]}' >"${malicious_xdg}/opencode/opencode.json" + printf '%s\n' '{"plugin":["evil-home"]}' >"${malicious_home}/.opencode/opencode.json" + + # shellcheck disable=SC2016 + run env HOME="${fake_home}" bash -euo pipefail -c ' + source "$1" + export XDG_CONFIG_HOME="$2" + export OPENCODE_TEST_HOME="$3" + export OPENCODE_PERMISSION="{\"*\":\"allow\"}" + [[ -f "${XDG_CONFIG_HOME}/opencode/opencode.json" ]] + [[ -f "${OPENCODE_TEST_HOME}/.opencode/opencode.json" ]] + opencode_review_strip_config_env + [[ -z "${XDG_CONFIG_HOME+x}" ]] + [[ -z "${OPENCODE_TEST_HOME+x}" ]] + [[ -z "${OPENCODE_PERMISSION+x}" ]] + [[ "${XDG_CONFIG_HOME:-${HOME}/.config}/opencode" == "${HOME}/.config/opencode" ]] + [[ "${OPENCODE_TEST_HOME:-${HOME}}/.opencode" == "${HOME}/.opencode" ]] + ' _ "${guard}" "${malicious_xdg}" "${malicious_home}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"Ignoring caller-provided XDG_CONFIG_HOME in review-only mode"* ]] + [[ "${output}" == *"Ignoring caller-provided OPENCODE_TEST_HOME in review-only mode"* ]] + [[ "${output}" == *"Ignoring caller-provided OPENCODE_PERMISSION in review-only mode"* ]] +} + +@test "review mode guard removes redirected XDG_DATA_HOME" { + malicious_xdg_data="${BATS_TEST_TMPDIR}/malicious-xdg-data" + mkdir -p "${malicious_xdg_data}/opencode" + printf '%s\n' '{"wellknown":["https://evil.example/plugin.json"]}' >"${malicious_xdg_data}/opencode/auth.json" + + # shellcheck disable=SC2016 + run env HOME="${fake_home}" bash -euo pipefail -c ' + source "$1" + export XDG_DATA_HOME="$2" + [[ -f "${XDG_DATA_HOME}/opencode/auth.json" ]] + opencode_review_strip_config_env + [[ -z "${XDG_DATA_HOME+x}" ]] + ' _ "${guard}" "${malicious_xdg_data}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"Ignoring caller-provided XDG_DATA_HOME in review-only mode"* ]] +} + +@test "review mode guard re-exports XDG_DATA_HOME to a fresh empty directory" { + # shellcheck disable=SC2016 + run env HOME="${fake_home}" bash -euo pipefail -c ' + source "$1" + opencode_review_strip_config_env + opencode_review_isolate_data_dir + [[ -n "${XDG_DATA_HOME+x}" ]] + [[ -d "${XDG_DATA_HOME}" ]] + [[ ! -e "${XDG_DATA_HOME}/opencode" ]] + [[ -z "$(ls -A "${XDG_DATA_HOME}")" ]] + stat -c "%a" "${XDG_DATA_HOME}" >/dev/null 2>&1 || stat -f "%Lp" "${XDG_DATA_HOME}" >/dev/null + ' _ "${guard}" + + [ "${status}" -eq 0 ] +} + +@test "review mode guard isolates data dir away from a malicious auth.json" { + malicious_xdg_data="${BATS_TEST_TMPDIR}/malicious-xdg-data" + mkdir -p "${malicious_xdg_data}/opencode" + printf '%s\n' '{"wellknown":["https://evil.example/plugin.json"]}' >"${malicious_xdg_data}/opencode/auth.json" + + # shellcheck disable=SC2016 + run env HOME="${fake_home}" bash -euo pipefail -c ' + source "$1" + export XDG_DATA_HOME="$2" + [[ -f "${XDG_DATA_HOME}/opencode/auth.json" ]] + opencode_review_strip_config_env + opencode_review_isolate_data_dir + [[ -n "${XDG_DATA_HOME+x}" ]] + [[ "${XDG_DATA_HOME}" != "$2" ]] + [[ ! -e "${XDG_DATA_HOME}/opencode/auth.json" ]] + ' _ "${guard}" "${malicious_xdg_data}" + + [ "${status}" -eq 0 ] +} + +@test "review mode version comparison does not require GNU sort" { + cat >"${fake_bin}/sort" <<'EOF' +#!/usr/bin/env bash +exit 99 +EOF + chmod +x "${fake_bin}/sort" + + # shellcheck disable=SC2016 + run env PATH="${fake_bin}:${PATH}" bash -euo pipefail -c ' + source "$1" + opencode_review_enforce_version_floor 1.1.29 + opencode_review_enforce_version_floor 2.0.0 + ' _ "${guard}" + + [ "${status}" -eq 0 ] +} diff --git a/.agents/skills/local-qa/scripts/test-review-pr-read-only.bats b/.agents/skills/local-qa/scripts/test-review-pr-read-only.bats index 1e27b24..bc57898 100644 --- a/.agents/skills/local-qa/scripts/test-review-pr-read-only.bats +++ b/.agents/skills/local-qa/scripts/test-review-pr-read-only.bats @@ -136,5 +136,192 @@ EOF grep -q 'rm -rf "${HOME}/.config/opencode"' "${action_yml}" # shellcheck disable=SC2016 grep -q 'cp -r "${ACTION_PATH}/.opencode/."' "${action_yml}" + grep -q "inputs.enable-toolkit == 'true' || steps.review_mode.outputs.enabled == 'true'" "${action_yml}" grep -q 'writeFileSync("pwned-by-project-plugin"' "${malicious_plugin}" } + +@test "review dispatch expands the trusted command and forces its orchestrator" { + guard="${repo_root}/.opencode/scripts/review-mode-guard.sh" + + # shellcheck disable=SC2016 + run env HOME="${fake_home}" bash -euo pipefail -c ' + source "$1" + opencode_review_prepare_dispatch "$2" "/review-pr security" "" + [[ "${OPENCODE_CONFIG_CONTENT}" == "{\"default_agent\":\"review-pr-orchestrator\"}" ]] + [[ "${PROMPT}" == *"# Strictly Read-Only PR Review"* ]] + [[ "${PROMPT}" == *"Requested review aspects: security"* ]] + [[ "${PROMPT}" != *"\$ARGUMENTS"* ]] + ' _ "${guard}" "${repo_root}" + + [ "${status}" -eq 0 ] +} + +@test "review dispatch extracts aspects from an issue comment" { + guard="${repo_root}/.opencode/scripts/review-mode-guard.sh" + + # shellcheck disable=SC2016 + run env HOME="${fake_home}" bash -euo pipefail -c ' + source "$1" + opencode_review_prepare_dispatch "$2" "" "/oc /review-pr tests" + [[ "${PROMPT}" == *"Requested review aspects: tests"* ]] + ' _ "${guard}" "${repo_root}" + + [ "${status}" -eq 0 ] +} + +@test "review-only success requires structured submission evidence" { + guard="${repo_root}/.opencode/scripts/review-mode-guard.sh" + state_dir="${fake_home}/.config/opencode/review-state" + mkdir -p "${state_dir}" + + # shellcheck disable=SC2016 + run env HOME="${fake_home}" bash -c 'source "$1"; opencode_review_require_submission' _ "${guard}" + [ "${status}" -ne 0 ] + + printf '%s' '555' >"${state_dir}/review_id" + # shellcheck disable=SC2016 + run env HOME="${fake_home}" bash -c 'source "$1"; opencode_review_require_submission' _ "${guard}" + [ "${status}" -eq 0 ] + + rm "${state_dir}/review_id" + printf '%s' 'No noteworthy issues found.' >"${state_dir}/no-findings" + # shellcheck disable=SC2016 + run env HOME="${fake_home}" bash -c 'source "$1"; opencode_review_require_submission' _ "${guard}" + [ "${status}" -eq 0 ] + + # shellcheck disable=SC2016 + grep -q '"$HOME/.config/opencode/review-state/no-findings": allow' "${orchestrator}" +} + +@test "initial submission revalidates the PR head immediately before the POST" { + write_resolver + printf '%s\n' '{"issue":{"number":42}}' >"${event_path}" + count_file="${BATS_TEST_TMPDIR}/gh-count" + post_marker="${BATS_TEST_TMPDIR}/gh-post" + printf '0' >"${count_file}" + cat >"${fake_bin}/gh" <"${count_file}" + if [[ "\$count" -lt 2 ]]; then + printf '%s\\n' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + else + printf '%s\\n' bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + fi +elif [[ "\$1" == "api" ]]; then + : >"${post_marker}" + jq -n '{id: 555}' +else + exit 1 +fi +EOF + chmod +x "${fake_bin}/gh" + prepare_state + run env HOME="${fake_home}" PATH="${fake_bin}:${PATH}" GITHUB_REPOSITORY="octo/repo" GITHUB_EVENT_PATH="${event_path}" bash "${helper}" context + [ "${status}" -eq 0 ] + printf '%s\n' '{"body":"Review","comments":[{"path":"x","line":1,"body":"finding"}]}' >"${fake_home}/.config/opencode/review-state/initial.json" + + run env HOME="${fake_home}" PATH="${fake_bin}:${PATH}" GITHUB_REPOSITORY="octo/repo" GITHUB_EVENT_PATH="${event_path}" bash "${submit}" submit-initial + + [ "${status}" -ne 0 ] + [[ "${output}" == *"PR head changed immediately before review submission"* ]] + [ ! -e "${post_marker}" ] +} + +@test "review update revalidates the PR head immediately before the PUT" { + write_resolver + printf '%s\n' '{"issue":{"number":42}}' >"${event_path}" + count_file="${BATS_TEST_TMPDIR}/gh-count" + put_marker="${BATS_TEST_TMPDIR}/gh-put" + printf '0' >"${count_file}" + cat >"${fake_bin}/gh" <"${count_file}" + if [[ "\$count" -lt 2 ]]; then + printf '%s\\n' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + else + printf '%s\\n' bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + fi +elif [[ "\$1" == "api" ]]; then + : >"${put_marker}" + jq -n '{id: 555}' +else + exit 1 +fi +EOF + chmod +x "${fake_bin}/gh" + prepare_state + run env HOME="${fake_home}" PATH="${fake_bin}:${PATH}" GITHUB_REPOSITORY="octo/repo" GITHUB_EVENT_PATH="${event_path}" bash "${helper}" context + [ "${status}" -eq 0 ] + printf '%s\n' '{"body":"Updated review"}' >"${fake_home}/.config/opencode/review-state/update.json" + printf '555' >"${fake_home}/.config/opencode/review-state/review_id" + + run env HOME="${fake_home}" PATH="${fake_bin}:${PATH}" GITHUB_REPOSITORY="octo/repo" GITHUB_EVENT_PATH="${event_path}" bash "${submit}" update + + [ "${status}" -ne 0 ] + [[ "${output}" == *"PR head changed immediately before review submission"* ]] + [ ! -e "${put_marker}" ] +} + +@test "review mode guard strips caller-controlled OpenCode config env vars" { + guard="${repo_root}/.opencode/scripts/review-mode-guard.sh" + + run bash -euo pipefail -c ' + source "$1" + export OPENCODE_CONFIG=/tmp/evil.json + export OPENCODE_CONFIG_DIR=/tmp/evil-dir + export OPENCODE_CONFIG_CONTENT="{\"plugin\":[\"evil\"]}" + opencode_review_strip_config_env + [[ -z "${OPENCODE_CONFIG+x}" ]] + [[ -z "${OPENCODE_CONFIG_DIR+x}" ]] + [[ -z "${OPENCODE_CONFIG_CONTENT+x}" ]] + ' _ "${guard}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"Ignoring caller-provided OPENCODE_CONFIG in review-only mode"* ]] + [[ "${output}" == *"Ignoring caller-provided OPENCODE_CONFIG_DIR in review-only mode"* ]] + [[ "${output}" == *"Ignoring caller-provided OPENCODE_CONFIG_CONTENT in review-only mode"* ]] +} + +@test "review mode guard strips caller-controlled XDG_DATA_HOME" { + guard="${repo_root}/.opencode/scripts/review-mode-guard.sh" + + run bash -euo pipefail -c ' + source "$1" + export XDG_DATA_HOME=/tmp/evil-data + opencode_review_strip_config_env + [[ -z "${XDG_DATA_HOME+x}" ]] + ' _ "${guard}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"Ignoring caller-provided XDG_DATA_HOME in review-only mode"* ]] +} + +@test "review mode guard enforces the OpenCode version floor and fails closed" { + guard="${repo_root}/.opencode/scripts/review-mode-guard.sh" + + for version in 1.1.29 1.1.30 1.2.0 2.0.0 10.0.0; do + run bash -euo pipefail -c 'source "$1"; opencode_review_enforce_version_floor "$2"' _ "${guard}" "${version}" + [ "${status}" -eq 0 ] + done + + for version in 1.1.28 1.0.99 0.9.9 "" latest 1.1 1.1.29-rc.1 v1.1.29 main; do + run bash -euo pipefail -c 'source "$1"; opencode_review_enforce_version_floor "$2"' _ "${guard}" "${version}" + [ "${status}" -ne 0 ] + done +} + +@test "action.yml applies the review mode guard before running OpenCode" { + grep -q 'Enforce review-only OpenCode version floor' "${action_yml}" + grep -q 'review-mode-guard.sh' "${action_yml}" + grep -q 'opencode_review_enforce_version_floor' "${action_yml}" + grep -q 'opencode_review_strip_config_env' "${action_yml}" + grep -q 'opencode_review_isolate_data_dir' "${action_yml}" + grep -q 'opencode_review_prepare_dispatch' "${action_yml}" + grep -q 'opencode_review_require_submission' "${action_yml}" + # shellcheck disable=SC2016 + grep -q 'REVIEW_ONLY: ${{ steps.review_mode.outputs.enabled }}' "${action_yml}" +} diff --git a/.opencode/agents/review-pr-orchestrator.md b/.opencode/agents/review-pr-orchestrator.md index 21958d9..261e9c9 100644 --- a/.opencode/agents/review-pr-orchestrator.md +++ b/.opencode/agents/review-pr-orchestrator.md @@ -14,6 +14,7 @@ permission: "*": deny "$HOME/.config/opencode/review-state/initial.json": allow "$HOME/.config/opencode/review-state/update.json": allow + "$HOME/.config/opencode/review-state/no-findings": allow glob: allow grep: allow bash: @@ -42,4 +43,4 @@ permission: code-simplifier: allow --- -Coordinate a strictly read-only review. Never modify the checkout. Use only the exact argument-free helper commands, the two fixed review-state JSON files, and the approved reviewer agents. +Coordinate a strictly read-only review. Never modify the checkout. Use only the exact argument-free helper commands, the fixed review-state payloads and no-findings marker, and the approved reviewer agents. diff --git a/.opencode/commands/review-pr.md b/.opencode/commands/review-pr.md index 3a43db8..9ea8287 100644 --- a/.opencode/commands/review-pr.md +++ b/.opencode/commands/review-pr.md @@ -9,7 +9,7 @@ This is a strictly read-only repository review. Analyze and report only. Do not Do not run repository-wide QA scripts, formatters, auto-fixing linters, generators, dependency installers, or anything that can create caches, reports, snapshots, lockfiles, coverage output, scan output, or configuration exports in the checkout. -Every helper this command invokes — the read-only `gh` wrapper, the constrained submission helper, and the App-token resolver they source — lives only at its `${HOME}/.config/opencode/scripts/` path, installed there by the action before the reviewed repository is ever checked out. Never invoke any of them by a repository-relative path such as `.opencode/scripts/...`: the checkout under review is untrusted input, and a repository-relative path would let a malicious PR that edits or adds a same-named file substitute its own script for the trusted one. These helper paths and the two fixed review-state JSON files are the sole allow-listed external paths. The helpers use `opencode_app_token_lib="${HOME}/.config/opencode/scripts/resolve-app-token.sh"` for authentication. +Every helper this command invokes — the read-only `gh` wrapper, the constrained submission helper, and the App-token resolver they source — lives only at its `${HOME}/.config/opencode/scripts/` path, installed there by the action before the reviewed repository is ever checked out. Never invoke any of them by a repository-relative path such as `.opencode/scripts/...`: the checkout under review is untrusted input, and a repository-relative path would let a malicious PR that edits or adds a same-named file substitute its own script for the trusted one. These helper paths, the fixed review-state JSON files, and the no-findings marker are the sole allow-listed external paths. The helpers use `opencode_app_token_lib="${HOME}/.config/opencode/scripts/resolve-app-token.sh"` for authentication. **Requested review aspects (optional):** "$ARGUMENTS" @@ -60,9 +60,9 @@ Do not let a reviewer post to GitHub. Drop praise, nitpicks, style-only feedback, findings outside the changed-file list, and duplicates. Keep the most specific actionable finding for each root cause. Classify every remaining finding as inline when its file and head-side changed line can be anchored in the captured diff; adjust only to a nearby relevant changed line. Put genuine but unanchorable findings in `summary_only` with a short reason. -If there are no findings, return exactly `No noteworthy issues found.` Do not post an empty review. +If there are no findings, use the edit tool to write exactly `No noteworthy issues found.` to `$HOME/.config/opencode/review-state/no-findings`, then return that exact text. Do not post an empty review. -For findings, the single `prepare` operation in section 1 has already created the empty payload files and pinned context. Do not run it again. Use the edit tool only for `$HOME/.config/opencode/review-state/initial.json`, writing exactly `{body, comments}` with a nonempty body and inline comments array. The helper validates the payload and adds the trusted `commit_id` and `event` itself. Each inline body is `** · **: `. +For findings, the single `prepare` operation in section 1 has already created the empty payload files, the empty no-findings marker, and pinned context. Do not run it again. Use the edit tool only for `$HOME/.config/opencode/review-state/initial.json`, writing exactly `{body, comments}` with a nonempty body and inline comments array. The helper validates the payload and adds the trusted `commit_id` and `event` itself. Each inline body is `** · **: `. Every finding with a valid diff anchor must be included in the `comments` array and submitted as an inline review comment. Never return anchorable findings only as top-level assistant text. If structured submission fails, fail the run instead of emitting the findings as a top-level completion comment. diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 16f0da7..c578090 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -8,7 +8,8 @@ "$HOME/.config/opencode/scripts/review-pr-gh.sh": "allow", "$HOME/.config/opencode/review-state/initial.json": "allow", "$HOME/.config/opencode/review-state/update.json": "allow", - "$HOME/.config/opencode/review-state/context.json": "allow" + "$HOME/.config/opencode/review-state/context.json": "allow", + "$HOME/.config/opencode/review-state/no-findings": "allow" } } } diff --git a/.opencode/scripts/review-mode-guard.sh b/.opencode/scripts/review-mode-guard.sh new file mode 100644 index 0000000..d38705e --- /dev/null +++ b/.opencode/scripts/review-mode-guard.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Review-only mode guards for action.yml. Intended to be sourced (not +# executed) from the composite action's steps so that stripping +# caller-controlled OpenCode config environment variables applies to the +# step shell that later invokes `opencode github run`. + +# First upstream release whose `opencode` honors +# OPENCODE_DISABLE_PROJECT_CONFIG: anomalyco/opencode commit +# a18ae2c8b7b29f89aa4bbe56d14b786f41c9f4f5 ("feat: add +# OPENCODE_DISABLE_PROJECT_CONFIG env var", #8093) first shipped in +# v1.1.29. Older versions silently ignore the variable and would load the +# reviewed project's .opencode/ config, so review-only mode must refuse to +# run them. +OPENCODE_REVIEW_MIN_VERSION="1.1.29" + +# Fail closed unless $1 is a canonical x.y.z release version at or above +# OPENCODE_REVIEW_MIN_VERSION. Anything unparseable (empty, "latest" left +# unresolved, branch names, pre-release suffixes) is rejected because +# OPENCODE_DISABLE_PROJECT_CONFIG support cannot be proven for it. +opencode_review_enforce_version_floor() { + local version="${1:-}" + local version_major version_minor version_patch + local minimum_major minimum_minor minimum_patch + + if [[ ! "${version}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Review-only mode requires a plain x.y.z OpenCode release version to prove OPENCODE_DISABLE_PROJECT_CONFIG support; got '${version}'." >&2 + return 1 + fi + + IFS=. read -r version_major version_minor version_patch <<<"${version}" + IFS=. read -r minimum_major minimum_minor minimum_patch <<<"${OPENCODE_REVIEW_MIN_VERSION}" + if (( + version_major < minimum_major || + (version_major == minimum_major && version_minor < minimum_minor) || + (version_major == minimum_major && version_minor == minimum_minor && version_patch < minimum_patch) + )); then + echo "::error::Review-only mode requires OpenCode >= ${OPENCODE_REVIEW_MIN_VERSION}, the first release that supports OPENCODE_DISABLE_PROJECT_CONFIG; resolved version ${version} would load the reviewed project's OpenCode config." >&2 + return 1 + fi +} + +# Unset every caller-controlled source that can redirect or override OpenCode +# configuration even when OPENCODE_DISABLE_PROJECT_CONFIG is set. This keeps +# review-only runs on the freshly installed toolkit under $HOME/.config and +# prevents direct permission overrides. XDG_DATA_HOME is also stripped here so +# the well-known XDG data path does not leak through; it is then re-exported +# by opencode_review_isolate_data_dir to a fresh empty directory below. +opencode_review_strip_config_env() { + local var + for var in \ + OPENCODE_CONFIG \ + OPENCODE_CONFIG_DIR \ + OPENCODE_CONFIG_CONTENT \ + OPENCODE_PERMISSION \ + OPENCODE_TEST_HOME \ + XDG_CONFIG_HOME \ + XDG_DATA_HOME; do + if [[ -n "${!var:-}" ]]; then + echo "::warning::Ignoring caller-provided ${var} in review-only mode." >&2 + fi + unset "${var}" + done +} + +# Create a fresh, empty XDG data directory and export XDG_DATA_HOME pointing +# at it. Merely unsetting XDG_DATA_HOME is not enough: OpenCode v1.1.29 +# derives Global.Path.data from XDG data paths and Auth.all() reads +# ${Global.Path.data}/auth.json, where every wellknown entry is fetched and +# merged as remote config (including plugin arrays) before the bundled +# global config. With XDG_DATA_HOME unset, OpenCode falls back to the +# persistent $HOME/.local/share tree, so a caller who can influence that +# location (or any inherited XDG_DATA_HOME) would still load arbitrary +# plugin code. Pointing XDG_DATA_HOME at a fresh empty mktemp -d directory +# breaks that path and prevents any auth.json or remote config from being +# loaded for review-only runs. The directory is left in place for the run +# and is not removed by the guard; the caller's normal tmp cleanup is +# responsible for it. +opencode_review_isolate_data_dir() { + local data_dir + if ! data_dir="$(mktemp -d -t opencode-review-data.XXXXXX 2>/dev/null)"; then + echo "::error::Review-only mode could not create an isolated OpenCode data directory." >&2 + return 1 + fi + chmod 700 "${data_dir}" + export XDG_DATA_HOME="${data_dir}" +} + +# The GitHub handler sends PROMPT through the ordinary prompt API and omits the +# agent field, so slash commands and command frontmatter are not applied there. +# Expand the trusted command text ourselves and select its constrained agent +# through trusted inline config after caller-provided config has been removed. +opencode_review_prepare_dispatch() { + local action_path="${1:-}" prompt="${2:-}" event_comment="${3:-}" + local arguments="" command_file command_text + + if [[ "${prompt}" =~ ^/review-pr([[:space:]]+(.*))?$ ]]; then + arguments="${BASH_REMATCH[2]:-}" + elif [[ "${event_comment}" =~ /review-pr([[:space:]]+(.*))?$ ]]; then + arguments="${BASH_REMATCH[2]:-}" + fi + + command_file="${action_path}/.opencode/commands/review-pr.md" + [[ -f "${command_file}" ]] || { + echo "::error::Trusted review command is unavailable." >&2 + return 1 + } + command_text="$(<"${command_file}")" + command_text="${command_text//\$ARGUMENTS/}" + if [[ -n "${arguments}" ]]; then + command_text+=$'\n\nRequested review aspects: ' + command_text+="${arguments}" + fi + + export OPENCODE_CONFIG_CONTENT='{"default_agent":"review-pr-orchestrator"}' + export PROMPT="${command_text}" +} + +# A successful process exit is insufficient in review-only mode: the GitHub +# handler can post an ordinary issue comment even when structured submission +# never happened. Require the constrained helper's review ID or the command's +# explicit no-findings marker. +opencode_review_require_submission() { + local state_dir="${HOME}/.config/opencode/review-state" + local review_id="" no_findings="" + + if [[ -f "${state_dir}/review_id" ]]; then + review_id="$(<"${state_dir}/review_id")" + fi + if [[ "${review_id}" =~ ^[1-9][0-9]*$ ]]; then + return 0 + fi + if [[ -f "${state_dir}/no-findings" ]]; then + no_findings="$(<"${state_dir}/no-findings")" + fi + if [[ "${no_findings}" == "No noteworthy issues found." ]]; then + return 0 + fi + + echo "::error::Review-only mode completed without a structured review ID or trusted no-findings marker." >&2 + return 1 +} diff --git a/.opencode/scripts/review-pr-submit.sh b/.opencode/scripts/review-pr-submit.sh index 1dc7bdb..cf0d3dc 100644 --- a/.opencode/scripts/review-pr-submit.sh +++ b/.opencode/scripts/review-pr-submit.sh @@ -7,6 +7,7 @@ context_file="${state_dir}/context.json" initial_payload="${state_dir}/initial.json" update_payload="${state_dir}/update.json" review_id_file="${state_dir}/review_id" +no_findings_file="${state_dir}/no-findings" load_token_lib() { local opencode_app_token_lib="${HOME}/.config/opencode/scripts/resolve-app-token.sh" @@ -30,13 +31,26 @@ trusted_context() { printf '%s\t%s\t%s\n' "${repo}" "${pr_number}" "${head_sha}" } +# Revalidate the live PR head against the pinned SHA immediately before a +# review write. trusted_context already checks the head, but the token +# identity verification that runs between that check and the actual +# POST/PUT makes network calls and takes time, so the head is re-read here +# to close that TOCTOU window as tightly as the GitHub API allows. +verify_head_unmoved() { + local pr_number="${1:-}" head_sha="${2:-}" current_head + current_head="$(gh pr view "${pr_number}" --json headRefOid --jq .headRefOid)" || + fail "Unable to revalidate the PR head immediately before review submission." + [[ "${current_head}" == "${head_sha}" ]] || + fail "PR head changed immediately before review submission." +} + operation="${1:-}" [[ "$#" -eq 1 ]] || fail "Review helper operations take no arguments." case "${operation}" in prepare) rm -rf "${state_dir}" - (umask 077; mkdir -p "${state_dir}"; : >"${context_file}"; : >"${initial_payload}"; : >"${update_payload}") + (umask 077; mkdir -p "${state_dir}"; : >"${context_file}"; : >"${initial_payload}"; : >"${update_payload}"; : >"${no_findings_file}") ;; submit-initial) load_token_lib @@ -49,6 +63,7 @@ case "${operation}" in trap 'rm -f "${request}"' EXIT jq --arg commit_id "${head_sha}" '. + {commit_id: $commit_id, event: "COMMENT"}' "${initial_payload}" >"${request}" opencode_require_app_token_for_review "${USE_GITHUB_TOKEN:-false}" "${repo}" "${pr_number}" + verify_head_unmoved "${pr_number}" "${head_sha}" response="$(gh api --method POST "repos/${repo}/pulls/${pr_number}/reviews" --input "${request}")" review_id="$(jq -r '.id // empty' <<<"${response}")" [[ "${review_id}" =~ ^[1-9][0-9]*$ ]] || fail "Review ID was not returned." @@ -59,13 +74,14 @@ case "${operation}" in load_token_lib opencode_prepare_gh_token "${USE_GITHUB_TOKEN:-false}" || true context="$(trusted_context)" || fail "Pinned PR context is unavailable or the PR head changed." - IFS=$'\t' read -r repo pr_number _ <<<"${context}" + IFS=$'\t' read -r repo pr_number head_sha <<<"${context}" jq -e 'keys == ["body"] and (.body | type == "string" and length > 0)' "${update_payload}" >/dev/null || fail "Invalid review update payload." [[ -f "${review_id_file}" ]] || fail "This run has no recorded review ID." review_id="$(cat "${review_id_file}")" [[ "${review_id}" =~ ^[1-9][0-9]*$ ]] || fail "Recorded review ID is invalid." opencode_require_app_token_for_review "${USE_GITHUB_TOKEN:-false}" "${repo}" "${pr_number}" + verify_head_unmoved "${pr_number}" "${head_sha}" gh api --method PUT "repos/${repo}/pulls/${pr_number}/reviews/${review_id}" --input "${update_payload}" ;; *) fail "Unsupported review submission operation." ;; diff --git a/README.md b/README.md index 0eca7c8..518f587 100644 --- a/README.md +++ b/README.md @@ -49,19 +49,19 @@ Then comment `/opencode` or `/oc` on an issue, pull request, or pull request rev ## Inputs -| Input | Required | Default | Description | -| ------------------ | -------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `model` | Yes | | Model to use, in `provider/model` format. | -| `agent` | No | `build` | OpenCode primary agent to use. Falls back to `default_agent` from config or `build` if not found. | -| `share` | No | `false` | Whether to share the OpenCode session. | -| `prompt` | No | | Custom prompt to override the default prompt. | -| `use-github-token` | No | `false` | Use `GITHUB_TOKEN` directly instead of OpenCode App token exchange. | -| `mentions` | No | `/opencode,/oc` | Comma-separated trigger phrases, matched case-insensitively. | -| `variant` | No | | Provider-specific model variant for reasoning effort, such as `high`, `max`, or `minimal`. | -| `oidc-base-url` | No | `https://api.opencode.ai` | Base URL for OIDC token exchange. Override only for a custom GitHub App installation. | -| `version` | No | `latest` | OpenCode version to install, such as `v1.2.3`; `latest` resolves the latest upstream release. | -| `enable-toolkit` | No | `true` | Install the action's bundled `.opencode/` agents, commands, and skills into `~/.config/opencode` (global config) before running. Existing files are preserved. | -| `timeout-minutes` | No | `60` | Maximum minutes to let `opencode github run` execute before it is killed (uses `timeout`/`gtimeout` when available; otherwise runs without enforced timeout). | +| Input | Required | Default | Description | +| ------------------ | -------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `model` | Yes | | Model to use, in `provider/model` format. | +| `agent` | No | `build` | OpenCode primary agent to use. Falls back to `default_agent` from config or `build` if not found. | +| `share` | No | `false` | Whether to share the OpenCode session. | +| `prompt` | No | | Custom prompt to override the default prompt. | +| `use-github-token` | No | `false` | Use `GITHUB_TOKEN` directly instead of OpenCode App token exchange. | +| `mentions` | No | `/opencode,/oc` | Comma-separated trigger phrases, matched case-insensitively. | +| `variant` | No | | Provider-specific model variant for reasoning effort, such as `high`, `max`, or `minimal`. | +| `oidc-base-url` | No | `https://api.opencode.ai` | Base URL for OIDC token exchange. Override only for a custom GitHub App installation. | +| `version` | No | `latest` | OpenCode version to install, such as `v1.2.3`; `latest` resolves the latest upstream release. | +| `enable-toolkit` | No | `true` | Install the action's bundled `.opencode/` agents, commands, and skills into `~/.config/opencode` (global config) before running. Existing files are preserved; review-only mode always installs a fresh copy. | +| `timeout-minutes` | No | `60` | Maximum minutes to let `opencode github run` execute before it is killed (uses `timeout`/`gtimeout` when available; otherwise runs without enforced timeout). | ## Outputs @@ -89,6 +89,14 @@ A successful structured review run produces one GitHub review summary (with its The bundled toolkit ships an `external_directory` permission in `.opencode/opencode.jsonc` (copied into `~/.config/opencode/` with the rest of the toolkit) that denies by default, so a stray attempt to read outside the checked-out repository, such as inspecting `/opt/pipx/logs/*` after a failed tool install, is denied immediately instead of blocking on the default "ask" prompt, which nothing can answer in a non-interactive GitHub Actions run and would otherwise hang until `timeout-minutes` kills it. The one narrow exception is `~/.config/opencode/scripts/resolve-app-token.sh` itself: `/review-pr` sources that bundled script from outside the checked-out repository to resolve the OpenCode App token, so it is individually allow-listed ahead of the catch-all deny rule; no other external path is permitted. +### Review-only mode hardening + +When the action detects a review-only run (a `/review-pr` prompt or trigger comment), it refuses to load any caller-controlled OpenCode configuration: + +- The resolved OpenCode version must be a plain `x.y.z` release at or above `1.1.29`, the first upstream release that supports `OPENCODE_DISABLE_PROJECT_CONFIG`. Older or unparseable versions fail the run instead of silently loading the reviewed project's `.opencode/` config. +- The `OPENCODE_CONFIG`, `OPENCODE_CONFIG_DIR`, and `OPENCODE_CONFIG_CONTENT` environment variables are unset (with a workflow warning) before `opencode github run` starts, because upstream honors them even when project config is disabled. The `OPENCODE_PERMISSION`, `OPENCODE_TEST_HOME`, `XDG_CONFIG_HOME`, and `XDG_DATA_HOME` variables are unset for the same reason, and `XDG_DATA_HOME` is then re-exported to a fresh, empty `mktemp -d` directory so the persistent `$HOME/.local/share` tree (or any inherited XDG data path) cannot supply an `auth.json` whose `wellknown` entries would be merged as remote config before the bundled global config. +- The PR head SHA is pinned when the review context is established and revalidated immediately before each review `POST`/`PUT`, so a head that moves during the run — including during token identity verification — aborts the submission. + ### Review author and the OpenCode App token When the default OpenCode GitHub App flow is used (`use-github-token: false`), `/review-pr` resolves every _candidate_ App token from git credential configuration (checking the local `http.https://github.com/.extraheader` key, `git config --get-urlmatch`, and `--get-regexp`/`--show-origin --get-regexp` across all config scopes to also cover includeIf/global-style credential files, matching only keys whose URL host is exactly `github.com`). None of these candidates is trusted on format alone: an `actions/checkout`-persisted `GITHUB_TOKEN` credential or a PAT can be written to the exact same git-config key in the exact same `x-access-token:` basic-auth shape as the real OpenCode App token, and a workflow can legitimately have both that checkout-persisted credential at the highest-priority key and a real OpenCode App token from a lower-priority source. So before any structured review write, `/review-pr` tries each candidate in order, verifying it by creating a throwaway pending PR review with it, checking the `user.login` on the response, and immediately deleting that pending review regardless of the outcome. The search stops at, and exports, the first candidate that verifies as `opencode-agent[bot]`; an earlier unverified candidate does not stop it from trying later ones. Every structured review submission, review-body update, and anchor-validation retry re-resolves and re-verifies immediately beforehand. diff --git a/action.yml b/action.yml index 718f622..4acd263 100644 --- a/action.yml +++ b/action.yml @@ -42,7 +42,7 @@ inputs: default: latest enable-toolkit: required: false - description: Install the action's bundled .opencode/ agents, commands, and skills into ~/.config/opencode (global config) before running. Existing files are preserved. + description: Install the action's bundled .opencode/ agents, commands, and skills into ~/.config/opencode (global config) before running. Existing files are preserved. Review-only mode always installs a fresh copy. default: 'true' timeout-minutes: required: false @@ -106,8 +106,18 @@ runs: review_only=true fi echo "enabled=${review_only}" >> "${GITHUB_OUTPUT}" + - name: Enforce review-only OpenCode version floor + if: steps.review_mode.outputs.enabled == 'true' + shell: bash -euo pipefail {0} + env: + ACTION_PATH: ${{ github.action_path }} + OPENCODE_VERSION: ${{ steps.version.outputs.opencode-version }} + run: | + # shellcheck source=.opencode/scripts/review-mode-guard.sh + source "${ACTION_PATH}/.opencode/scripts/review-mode-guard.sh" + opencode_review_enforce_version_floor "${OPENCODE_VERSION}" - name: Copy bundled OpenCode config - if: inputs.enable-toolkit == 'true' + if: inputs.enable-toolkit == 'true' || steps.review_mode.outputs.enabled == 'true' shell: bash -euo pipefail {0} env: ACTION_PATH: ${{ github.action_path }} @@ -139,8 +149,18 @@ runs: VARIANT: ${{ inputs.variant }} OIDC_BASE_URL: ${{ inputs.oidc-base-url }} TIMEOUT_MINUTES: ${{ inputs.timeout-minutes }} + ACTION_PATH: ${{ github.action_path }} + REVIEW_ONLY: ${{ steps.review_mode.outputs.enabled }} + EVENT_COMMENT: ${{ github.event.comment.body }} OPENCODE_DISABLE_PROJECT_CONFIG: ${{ steps.review_mode.outputs.enabled == 'true' && '1' || '' }} run: | + if [[ "${REVIEW_ONLY}" == "true" ]]; then + # shellcheck source=.opencode/scripts/review-mode-guard.sh + source "${ACTION_PATH}/.opencode/scripts/review-mode-guard.sh" + opencode_review_strip_config_env + opencode_review_isolate_data_dir + opencode_review_prepare_dispatch "${ACTION_PATH}" "${PROMPT}" "${EVENT_COMMENT}" + fi output_file="$(mktemp)" trap 'rm -f "${output_file}"' EXIT timeout_cmd=() @@ -166,3 +186,7 @@ runs: fi exit "${opencode_status}" fi + + if [[ "${REVIEW_ONLY}" == "true" ]]; then + opencode_review_require_submission + fi