In CI, Shipmoor Code Review posts advisory PR comments that never gate the build. The deterministic gate stays the shipmoor scan exit code. The review is layered on top as advice. Because a review drives a bring-your-own-agent that can hold a provider key and read source, the reference design uses a split workflow so an untrusted fork PR can never combine those two.
The reference example ships in the Shipmoor CLI repository at .github/workflows/shipmoor-review.yml (two workflows kept in one file for shipping; copy them into two files to adopt), with the rationale and a per-agent decision table in shipmoor-review/ci/README.md.
The one non-negotiable: advisory never gates
The gate is the exit code of the shipmoor scan --fail-on step: exit 1 fails the job, exit 0 passes it. The advisory reviewer (shipmoor review --json plus the poster) is a step that:
- runs
if: always()(so you still get advice on a change that just failed the gate, the most useful moment), - is
|| true-guarded (so a reviewer crash, a bring-your-own-agent timeout, or a poster error can never change the job’s conclusion), and - writes to a separate artifact (the review JSON, never the gate’s SARIF).
This is reinforced by the structural guarantee: code_review findings are gate-excluded, so even if you read them, the build’s verdict stays the scan exit code.
The split-workflow trust model
A shipmoor review agent executes tools (file_read, code_search, file_read_diff, file_find) over the checked-out tree, and that agent is your own arbitrary command. A single job that both checks out untrusted PR head and holds the bring-your-own-agent key would let a malicious fork exfiltrate that secret. So the reference splits into two workflows:
| Workflow A: build | Workflow B: post | |
|---|---|---|
| Trigger | pull_request | workflow_run (on A completing) |
| Secrets | none | yes (GITHUB_TOKEN; bring-your-own-agent keys if needed) |
| Permissions | contents: read (no pull-requests: write) | pull-requests: write, contents: read |
| Checks out PR head? | yes, safe, nothing to steal | never |
| What it does | runs the gate on real PR code and uploads the review JSON | downloads the artifact, capability-gates, posts comments |
Why it is safe: Workflow A runs untrusted PR code but holds no secrets. Workflow B holds secrets and write permission but never checks out or executes PR code; it only consumes the trusted artifact A produced. It even resolves the PR number from the artifact, not from the workflow_run head ref (which an attacker could influence).
Workflow A: build (gate plus advisory artifact)
Untrusted PR head, no secrets. The deterministic gate is authoritative; the advisory step is best-effort and uploaded separately.
name: Shipmoor PR (build)
on:
pull_request:
permissions:
contents: read # NO pull-requests: write here
jobs:
build-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for the merge-base diff
ref: ${{ github.event.pull_request.head.sha }}
- name: Install Shipmoor CLI
run: |
curl -fsSL https://dl.shipmoor.dev/install.sh | bash
echo "$HOME/.shipmoor/bin" >> "$GITHUB_PATH"
# The DETERMINISTIC GATE — its exit code is the authoritative pass/fail.
- name: Shipmoor gate (deterministic, authoritative)
run: shipmoor scan . --sarif --output shipmoor.sarif --fail-on high
# Advisory review — `|| true` so a reviewer crash never flips the gate.
# Runs in the no-secrets job, so use a LOCAL/offline agent here. A network
# agent that needs a key must NOT run here — see "Per-agent placement".
- name: Shipmoor review (advisory, non-gating)
if: ${{ always() }}
env:
SHIPMOOR_AGENT: ${{ vars.SHIPMOOR_LOCAL_AGENT }}
run: |
if [[ -n "${SHIPMOOR_AGENT}" ]]; then
shipmoor review . \
--from "origin/${{ github.event.pull_request.base.ref }}" \
--to "${{ github.event.pull_request.head.sha }}" \
--agent "${SHIPMOOR_AGENT}" \
--json --output "${RUNNER_TEMP}/shipmoor-review.json" \
2> "${RUNNER_TEMP}/shipmoor-review.stderr" || true
else
echo '{"schema_version":"shipmoor.scan.v1","findings":[]}' > "${RUNNER_TEMP}/shipmoor-review.json"
fi
mkdir -p "${RUNNER_TEMP}/shipmoor-artifact"
cp "${RUNNER_TEMP}/shipmoor-review.json" "${RUNNER_TEMP}/shipmoor-artifact/"
echo "${{ github.event.pull_request.number }}" > "${RUNNER_TEMP}/shipmoor-artifact/pr-number.txt"
- uses: actions/upload-artifact@v4
with:
name: shipmoor-review
path: ${{ runner.temp }}/shipmoor-artifact/
if-no-files-found: error
Workflow B: post (trusted, never executes PR code)
Holds secrets and pull-requests: write. Downloads A’s artifact, capability-gates on cli_pro, and posts. No actions/checkout of PR head appears anywhere.
name: Shipmoor PR (post)
on:
workflow_run:
workflows: ["Shipmoor PR (build)"]
types: [completed]
permissions:
pull-requests: write
contents: read
jobs:
post:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.event == 'pull_request' }}
steps:
- name: Download advisory review artifact
uses: actions/download-artifact@v4
with:
name: shipmoor-review
path: shipmoor-review-artifact
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Resolve PR number from artifact # never the workflow_run ref
id: pr
run: |
num="$(cat shipmoor-review-artifact/pr-number.txt | tr -dc '0-9')"
[[ -z "$num" ]] && { echo "Nothing to post."; exit 0; }
echo "number=$num" >> "$GITHUB_OUTPUT"
- name: Install Shipmoor CLI # source-free capability probe
if: ${{ steps.pr.outputs.number != '' }}
run: |
curl -fsSL https://dl.shipmoor.dev/install.sh | bash
echo "$HOME/.shipmoor/bin" >> "$GITHUB_PATH"
- name: Capability gate
id: capgate
if: ${{ steps.pr.outputs.number != '' }}
run: |
caps="$(shipmoor capabilities --json 2>/dev/null || echo '{}')"
enabled="$(printf '%s' "$caps" | jq -r '.capabilities.cli_pro.enabled // false')"
echo "review_enabled=$enabled" >> "$GITHUB_OUTPUT"
- name: Post advisory review comments
if: ${{ steps.pr.outputs.number != '' && steps.capgate.outputs.review_enabled == 'true' }}
uses: actions/github-script@v7
env:
SHIPMOOR_REVIEW_JSON: shipmoor-review-artifact/shipmoor-review.json
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prNumber = parseInt(${{ steps.pr.outputs.number }}, 10);
context.issue.number = prNumber;
context.payload.issue = { number: prNumber };
const post = require('./shipmoor-review/ci/post_review.js');
await post({ github, context, core });
The poster (shipmoor-review/ci/post_review.js) reads the canonical shipmoor.scan.v1 findings[] and posts them with a fixed advisory disclaimer: “These are advisory, model-derived suggestions. They do not affect the Shipmoor gate.” It never echoes raw agent stderr (which could leak prompts, source, or keys).
Capability gating (cli_pro) in CI
The poster gates on the cli_pro entitlement via a source-free probe:
shipmoor capabilities --json | jq -r '.capabilities.cli_pro.enabled'
If cli_pro is not enabled, the post step is skipped. The gate (Workflow A) still runs and still decides the build. A locked entitlement degrades the advisory layer gracefully; it never breaks the build.
Per-agent placement (forks)
Where the review runs depends on what your agent needs:
| Agent profile | Needs a secret key? | Reads the tree via tools? | Where to run |
|---|---|---|---|
| Local or offline agent | no | yes or no | Workflow A: no secret to leak; full tool access over PR head is fine. |
| Network agent, diff-only (reasons over the diff text only) | yes | no | Workflow B over the diff text only: no tool execution against attacker-controlled source. |
| Network agent that reads the tree | yes | yes | Do not run for fork PRs. Same-repo PRs only, or skip advisory for forks. Never combine PR-head checkout and the agent key in one job. |
Secrets and egress notes
- Bring-your-own-agent provider keys come from Actions secrets only, injected into the agent command’s own env. The only review-time network egress is your agent’s own provider call. The Shipmoor binary itself makes no review-time network call. On a locked-down runner, allowlist only your agent’s provider endpoint.
- The poster needs only
GITHUB_TOKENpluspull-requests: write.
See also
- The gate-versus-advisory exit-code model: CLI reference.
- The agent wire contract: AI coding agents.
- The deterministic scan gate in GitHub Actions: GitHub Actions.