From 5c75aef581087fa78998691f75856c2893841e96 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 11 Jul 2026 10:43:54 +0000 Subject: [PATCH 1/6] Harden review-only mode config isolation and close review submission TOCTOU window Co-Authored-By: Claude Fable 5 --- .../scripts/test-review-pr-read-only.bats | 116 ++++++++++++++++++ .opencode/scripts/review-mode-guard.sh | 44 +++++++ .opencode/scripts/review-pr-submit.sh | 17 ++- README.md | 8 ++ action.yml | 17 +++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 .opencode/scripts/review-mode-guard.sh 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..2a7ffd8 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 @@ -138,3 +138,119 @@ EOF grep -q 'cp -r "${ACTION_PATH}/.opencode/."' "${action_yml}" grep -q 'writeFileSync("pwned-by-project-plugin"' "${malicious_plugin}" } + +@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 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}" + # shellcheck disable=SC2016 + grep -q 'REVIEW_ONLY: ${{ steps.review_mode.outputs.enabled }}' "${action_yml}" +} diff --git a/.opencode/scripts/review-mode-guard.sh b/.opencode/scripts/review-mode-guard.sh new file mode 100644 index 0000000..1c6b14d --- /dev/null +++ b/.opencode/scripts/review-mode-guard.sh @@ -0,0 +1,44 @@ +#!/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 plain 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:-}" + if [[ ! "${version}" =~ ^[0-9]+\.[0-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 + if [[ "$(printf '%s\n' "${OPENCODE_REVIEW_MIN_VERSION}" "${version}" | sort -V | head -n1)" != "${OPENCODE_REVIEW_MIN_VERSION}" ]]; 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 the caller-controlled OpenCode config sources that upstream still +# honors even when OPENCODE_DISABLE_PROJECT_CONFIG is set, so a workflow +# (or a step that ran before this action) cannot inject configuration into +# a review-only run. +opencode_review_strip_config_env() { + local var + for var in OPENCODE_CONFIG OPENCODE_CONFIG_DIR OPENCODE_CONFIG_CONTENT; do + if [[ -n "${!var:-}" ]]; then + echo "::warning::Ignoring caller-provided ${var} in review-only mode." >&2 + fi + unset "${var}" + done +} diff --git a/.opencode/scripts/review-pr-submit.sh b/.opencode/scripts/review-pr-submit.sh index 1dc7bdb..b9ef116 100644 --- a/.opencode/scripts/review-pr-submit.sh +++ b/.opencode/scripts/review-pr-submit.sh @@ -30,6 +30,19 @@ 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." @@ -49,6 +62,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 +73,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..c8c2b49 100644 --- a/README.md +++ b/README.md @@ -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 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..38f00ca 100644 --- a/action.yml +++ b/action.yml @@ -106,6 +106,16 @@ 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' shell: bash -euo pipefail {0} @@ -139,8 +149,15 @@ 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 }} 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 + fi output_file="$(mktemp)" trap 'rm -f "${output_file}"' EXIT timeout_cmd=() From 0ac2e7a1cdbcc4980e7013d74b715c77a630083f Mon Sep 17 00:00:00 2001 From: Daichi Narushima <1938249+dceoy@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:03:56 +0900 Subject: [PATCH 2/6] Address review-only guard feedback --- .opencode/scripts/review-mode-guard.sh | 32 +++++++++++++++++++------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/.opencode/scripts/review-mode-guard.sh b/.opencode/scripts/review-mode-guard.sh index 1c6b14d..46ac663 100644 --- a/.opencode/scripts/review-mode-guard.sh +++ b/.opencode/scripts/review-mode-guard.sh @@ -13,29 +13,45 @@ # run them. OPENCODE_REVIEW_MIN_VERSION="1.1.29" -# Fail closed unless $1 is a plain x.y.z release version at or above +# 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:-}" - if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + 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 - if [[ "$(printf '%s\n' "${OPENCODE_REVIEW_MIN_VERSION}" "${version}" | sort -V | head -n1)" != "${OPENCODE_REVIEW_MIN_VERSION}" ]]; then + + 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 the caller-controlled OpenCode config sources that upstream still -# honors even when OPENCODE_DISABLE_PROJECT_CONFIG is set, so a workflow -# (or a step that ran before this action) cannot inject configuration into -# a review-only run. +# 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. opencode_review_strip_config_env() { local var - for var in OPENCODE_CONFIG OPENCODE_CONFIG_DIR OPENCODE_CONFIG_CONTENT; do + for var in \ + OPENCODE_CONFIG \ + OPENCODE_CONFIG_DIR \ + OPENCODE_CONFIG_CONTENT \ + OPENCODE_PERMISSION \ + OPENCODE_TEST_HOME \ + XDG_CONFIG_HOME; do if [[ -n "${!var:-}" ]]; then echo "::warning::Ignoring caller-provided ${var} in review-only mode." >&2 fi From b2815b245b1b8bffbc226e48e87676b60a9b4535 Mon Sep 17 00:00:00 2001 From: Daichi Narushima <1938249+dceoy@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:04:09 +0900 Subject: [PATCH 3/6] Add regression tests for review guard feedback --- .../scripts/test-review-mode-guard.bats | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .agents/skills/local-qa/scripts/test-review-mode-guard.bats 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..076a8fa --- /dev/null +++ b/.agents/skills/local-qa/scripts/test-review-mode-guard.bats @@ -0,0 +1,53 @@ +#!/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" + + 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 version comparison does not require GNU sort" { + cat >"${fake_bin}/sort" <<'EOF' +#!/usr/bin/env bash +exit 99 +EOF + chmod +x "${fake_bin}/sort" + + 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 ] +} From f4bced278f06bd618d1eb748a6f976eac9442fbf Mon Sep 17 00:00:00 2001 From: Daichi Narushima <1938249+dceoy@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:13:33 +0900 Subject: [PATCH 4/6] Fix ShellCheck warnings in guard tests --- .agents/skills/local-qa/scripts/test-review-mode-guard.bats | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agents/skills/local-qa/scripts/test-review-mode-guard.bats b/.agents/skills/local-qa/scripts/test-review-mode-guard.bats index 076a8fa..f06ccbc 100644 --- a/.agents/skills/local-qa/scripts/test-review-mode-guard.bats +++ b/.agents/skills/local-qa/scripts/test-review-mode-guard.bats @@ -15,6 +15,7 @@ setup() { 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" @@ -43,6 +44,7 @@ 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 From 35341e3d49d54ecb8aa9a1ef36123d0c9c2689b6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 11 Jul 2026 17:11:27 +0000 Subject: [PATCH 5/6] Triaged: 2 already fixed, 1 fixed locally. Co-authored-by: dceoy --- .../scripts/test-review-mode-guard.bats | 54 +++++++++++++++++++ .../scripts/test-review-pr-read-only.bats | 15 ++++++ .opencode/scripts/review-mode-guard.sh | 30 ++++++++++- README.md | 2 +- action.yml | 1 + 5 files changed, 99 insertions(+), 3 deletions(-) diff --git a/.agents/skills/local-qa/scripts/test-review-mode-guard.bats b/.agents/skills/local-qa/scripts/test-review-mode-guard.bats index f06ccbc..86a5921 100644 --- a/.agents/skills/local-qa/scripts/test-review-mode-guard.bats +++ b/.agents/skills/local-qa/scripts/test-review-mode-guard.bats @@ -37,6 +37,60 @@ setup() { [[ "${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 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 2a7ffd8..bf64f68 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 @@ -232,6 +232,20 @@ EOF [[ "${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" @@ -251,6 +265,7 @@ EOF 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}" # shellcheck disable=SC2016 grep -q 'REVIEW_ONLY: ${{ steps.review_mode.outputs.enabled }}' "${action_yml}" } diff --git a/.opencode/scripts/review-mode-guard.sh b/.opencode/scripts/review-mode-guard.sh index 46ac663..64882d6 100644 --- a/.opencode/scripts/review-mode-guard.sh +++ b/.opencode/scripts/review-mode-guard.sh @@ -42,7 +42,9 @@ opencode_review_enforce_version_floor() { # 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. +# 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 \ @@ -51,10 +53,34 @@ opencode_review_strip_config_env() { OPENCODE_CONFIG_CONTENT \ OPENCODE_PERMISSION \ OPENCODE_TEST_HOME \ - XDG_CONFIG_HOME; do + 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}" +} diff --git a/README.md b/README.md index c8c2b49..ac82822 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ The bundled toolkit ships an `external_directory` permission in `.opencode/openc 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_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 diff --git a/action.yml b/action.yml index 38f00ca..8ac8467 100644 --- a/action.yml +++ b/action.yml @@ -157,6 +157,7 @@ runs: # 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 fi output_file="$(mktemp)" trap 'rm -f "${output_file}"' EXIT From 3fc69e08f8b481ab3588db4894b6c9c7e5118c07 Mon Sep 17 00:00:00 2001 From: dceoy <1938249+dceoy@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:50:45 +0900 Subject: [PATCH 6/6] harden structured review dispatch --- .../scripts/test-review-pr-read-only.bats | 56 +++++++++++++++++++ .opencode/agents/review-pr-orchestrator.md | 3 +- .opencode/commands/review-pr.md | 6 +- .opencode/opencode.jsonc | 3 +- .opencode/scripts/review-mode-guard.sh | 55 ++++++++++++++++++ .opencode/scripts/review-pr-submit.sh | 3 +- README.md | 26 ++++----- action.yml | 10 +++- 8 files changed, 141 insertions(+), 21 deletions(-) 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 bf64f68..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,9 +136,63 @@ 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}" @@ -266,6 +320,8 @@ EOF 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 index 64882d6..d38705e 100644 --- a/.opencode/scripts/review-mode-guard.sh +++ b/.opencode/scripts/review-mode-guard.sh @@ -84,3 +84,58 @@ opencode_review_isolate_data_dir() { 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 b9ef116..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" @@ -49,7 +50,7 @@ operation="${1:-}" 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 diff --git a/README.md b/README.md index ac82822..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 diff --git a/action.yml b/action.yml index 8ac8467..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 @@ -117,7 +117,7 @@ runs: 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 }} @@ -151,6 +151,7 @@ runs: 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 @@ -158,6 +159,7 @@ runs: 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 @@ -184,3 +186,7 @@ runs: fi exit "${opencode_status}" fi + + if [[ "${REVIEW_ONLY}" == "true" ]]; then + opencode_review_require_submission + fi