Skip to content

Commit 01bedc9

Browse files
committed
feat(security): encrypt agent share links (RSA-OAEP, admin-only recovery)
- share-filter.sh pipes every "opencode run --share" stream: the raw URL is add-mask::ed and never reaches the public log; the link is republished RSA-OAEP-encrypted (MRB1.<base64>) inline + notice annotation + step summary, with public context (repo/thread/head/type) - encryption happens bash-side after the stream ends (awk spawns no subprocesses - MSYS argument conversion mangles -pkeyopt colon args when spawned from awk; retry loop covers AV races on fresh files) - decrypt_share_link.py: TUI decryptor + "setup" (keygen + push SHARE_LINK_PUBKEY via gh, manual-steps fallback) + run browser (fetch run logs, decrypt every MRB1 blob with metadata); persistence opt-in only (config created on first change, never by default) - scrub-fixtures: 20 share-contract pins (filter pipe, secret env, /tmp copy, summary step, pipefail) - 77 checks total
1 parent 808f209 commit 01bedc9

7 files changed

Lines changed: 847 additions & 8 deletions

File tree

‎.github/scripts/scrub-fixtures.sh‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,20 @@ check "stub: label gate + decide/signal steps intact" yes "$(grep -c "Age
149149
check "review: NO workflow_run listener (dispatch only)" no "$(grep -q 'workflow_run:' "$PRWF" && echo yes || echo no)"
150150
check "review: auto context keyed on source=stub input" yes "$(grep -q "inputs.source == 'stub'" "$PRWF" && echo yes || echo no)"
151151

152+
# ---- share-link filter contract (drift tripwire) ---------------------------
153+
# Every opencode --share invocation MUST pipe through the trusted /tmp copy
154+
# of share-filter.sh (raw share URLs must never reach the public log), every
155+
# agent step must pass the SHARE_LINK_PUBKEY secret, and the summary step
156+
# must exist to surface the encrypted block on the run page.
157+
for wf in pr-review bot-reply compliance-check issue-comment; do
158+
WFF="$SCRIPT_DIR/../workflows/$wf.yml"
159+
check "share: $wf pipes --share through filter" yes "$(grep -q 'opencode run --share.*| bash /tmp/share-filter.sh' "$WFF" && echo yes || echo no)"
160+
check "share: $wf passes SHARE_LINK_PUBKEY env" yes "$(grep -q 'SHARE_LINK_PUBKEY: \${{ secrets.SHARE_LINK_PUBKEY }}' "$WFF" && echo yes || echo no)"
161+
check "share: $wf copies filter to /tmp" yes "$(grep -q 'cp .github/scripts/share-filter.sh /tmp/share-filter.sh' "$WFF" && echo yes || echo no)"
162+
check "share: $wf has summary step" yes "$(grep -q 'Share link summary' "$WFF" && echo yes || echo no)"
163+
check "share: $wf step sets pipefail" yes "$(grep -B15 'opencode run --share' "$WFF" | grep -q 'set -o pipefail' && echo yes || echo no)"
164+
done
165+
152166
# ---- channel hygiene -------------------------------------------------------
153167
git checkout -q --detach origin/evil; rm -f /tmp/scrub-taint.txt; bash "$SCRUB" --anchor main >/dev/null 2>&1
154168
flat=$(tr '\n' ' ' < /tmp/scrub-taint.txt | tr -s ' ' | cut -c1-600)

‎.github/scripts/share-filter.sh‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env bash
2+
# share-filter.sh - stream filter for `opencode run --share` invocations.
3+
#
4+
# Captures the session share link (https://opncd.ai/share/...) from the
5+
# output stream WITHOUT letting the raw URL reach the public Actions log,
6+
# then re-publishes it RSA-OAEP-encrypted so an admin holding the private
7+
# key can recover it (decrypt_share_link.py in the repo root).
8+
#
9+
# Architecture note: the awk stage NEVER spawns subprocesses (it only
10+
# captures/masks/writes files) - encryption happens in this bash script
11+
# after the stream ends. Spawning openssl from inside awk is fragile on
12+
# MSYS/Windows (argument conversion mangles "-pkeyopt name:value") and
13+
# gains nothing: the encrypted block lands at the end of the stream,
14+
# plus a ::notice:: annotation and the step-summary files.
15+
#
16+
# Usage:
17+
# opencode run --share - < prompt.txt | bash /tmp/share-filter.sh
18+
# (the calling step must set `set -o pipefail` to preserve the exit code)
19+
#
20+
# Env:
21+
# SHARE_LINK_PUBKEY PEM public key (repo secret SHARE_LINK_PUBKEY).
22+
# Optional - when unset/invalid the URL is still
23+
# captured and masked, just not encrypted.
24+
# SHARE_CTX_THREAD e.g. "PR #162" / "Issue #9" (optional)
25+
# SHARE_CTX_HEAD commit SHA this run reviewed (optional)
26+
# SHARE_CTX_DETAIL e.g. "FOLLOW-UP (last reviewed abc..)" (optional)
27+
# GITHUB_REPOSITORY / GITHUB_RUN_ID / GITHUB_ACTOR - default CI env.
28+
#
29+
# Outputs:
30+
# stdout: passthrough log; the link line is replaced by a capture notice,
31+
# the encrypted block is emitted at end of stream.
32+
# ::add-mask::<url> prevents any later accidental re-echo
33+
# ::notice:: prominent annotation carrying the blob
34+
# $TMP/share-link.enc / $TMP/share-link.ctx - for the step summary
35+
set -euo pipefail
36+
37+
TMP="${RUNNER_TEMP:-/tmp}"
38+
ENC_OUT="$TMP/share-link.enc"
39+
CTX_OUT="$TMP/share-link.ctx"
40+
URL_OUT="$TMP/share-link.url"
41+
42+
# ---- stage 1: stream filter (pure awk, no subprocesses) -------------------
43+
awk -v url_out="$URL_OUT" -v ctx_out="$CTX_OUT" \
44+
-v repo="${GITHUB_REPOSITORY:-unknown}" -v run="${GITHUB_RUN_ID:-unknown}" \
45+
-v actor="${GITHUB_ACTOR:-unknown}" \
46+
-v thread="${SHARE_CTX_THREAD:-}" -v head="${SHARE_CTX_HEAD:-}" \
47+
-v detail="${SHARE_CTX_DETAIL:-}" '
48+
BEGIN { captured = 0 }
49+
{
50+
if ($0 ~ /opncd\.ai\/share\//) {
51+
line = $0
52+
esc = sprintf("%c", 27)
53+
gsub(esc "\\[[0-9;]*m", "", line) # strip ANSI color codes
54+
if (match(line, /https:\/\/opncd\.ai\/share\/[A-Za-z0-9_-]+/)) {
55+
url = substr(line, RSTART, RLENGTH)
56+
printf "::add-mask::%s\n", url
57+
if (captured++) {
58+
print "~ [share link repeated - already captured above]"
59+
next
60+
}
61+
print "~ [share link captured - encrypted block at end of stream]"
62+
printf "%s\n", url > url_out
63+
ctx = "context: " repo
64+
if (thread != "") ctx = ctx " | " thread
65+
if (head != "") ctx = ctx " | head " head
66+
if (detail != "") ctx = ctx " | " detail
67+
ctx = ctx " | run " run " | by " actor
68+
printf "%s\n", ctx > ctx_out
69+
next
70+
}
71+
}
72+
print
73+
}
74+
'
75+
76+
# ---- stage 2: encrypt (bash-side, retry-tolerant) -------------------------
77+
if [ ! -s "$URL_OUT" ]; then
78+
exit 0 # nothing captured
79+
fi
80+
81+
CTX="$(cat "$CTX_OUT" 2>/dev/null || true)"
82+
83+
PUBKEY_FILE=""
84+
if [ -n "${SHARE_LINK_PUBKEY:-}" ]; then
85+
PUBKEY_FILE="$TMP/share-link.pub.pem"
86+
printf '%s\n' "$SHARE_LINK_PUBKEY" > "$PUBKEY_FILE"
87+
fi
88+
89+
encrypt_url() { # $1=url $2=pubkey-file -> base64 blob on stdout
90+
printf '%s' "$1" \
91+
| openssl pkeyutl -encrypt -pubin -inkey "$2" \
92+
-pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 \
93+
2>/dev/null \
94+
| openssl base64 -A
95+
echo # terminate for $( )
96+
}
97+
98+
URL="$(cat "$URL_OUT")"
99+
BLOB=""
100+
if [ -n "$PUBKEY_FILE" ]; then
101+
# retry loop: fresh files can be briefly unreadable (AV scanners, slow
102+
# network filesystems) - up to ~3s total
103+
for _ in 1 2 3 4 5 6 7 8 9 10; do
104+
BLOB="$(encrypt_url "$URL" "$PUBKEY_FILE")"
105+
[ -n "$BLOB" ] && break
106+
sleep 0.3
107+
done
108+
fi
109+
110+
if [ -n "$BLOB" ]; then
111+
printf '~ [share link encrypted] MRB1.%s\n' "$BLOB"
112+
printf '%s\n' "$CTX"
113+
printf '::notice::title=Mirrobot share link (encrypted)::MRB1.%s\n' "$BLOB"
114+
printf 'MRB1.%s\n' "$BLOB" > "$ENC_OUT"
115+
else
116+
echo "~ [share link captured - NOT encrypted: no valid SHARE_LINK_PUBKEY secret]"
117+
printf '%s\n' "$CTX"
118+
echo "::notice::title=Mirrobot share link::captured and masked; set the SHARE_LINK_PUBKEY secret to enable encryption"
119+
printf '(masked)\n' > "$ENC_OUT"
120+
fi
121+
printf '%s\n' "$CTX" > "$CTX_OUT"
122+
rm -f "$URL_OUT"

‎.github/workflows/bot-reply.yml‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -483,11 +483,12 @@ jobs:
483483
cp -r .github/prompts/manifests /tmp/manifests
484484
cp .github/scripts/assemble-prompt.sh /tmp/assemble-prompt.sh
485485
cp .github/scripts/scrub-workspace.sh /tmp/scrub-workspace.sh
486+
cp .github/scripts/share-filter.sh /tmp/share-filter.sh
486487
cp .github/scripts/fetch-roster.sh /tmp/fetch-roster.sh
487488
cp .github/scripts/react.sh /tmp/react.sh
488489
cp .github/scripts/fetch-pr-discussion.sh /tmp/fetch-pr-discussion.sh
489490
cp .github/scripts/generate-review-kit.sh /tmp/generate-review-kit.sh
490-
chmod +x /tmp/scrub-workspace.sh /tmp/assemble-prompt.sh /tmp/fetch-pr-discussion.sh /tmp/generate-review-kit.sh /tmp/react.sh
491+
chmod +x /tmp/scrub-workspace.sh /tmp/assemble-prompt.sh /tmp/fetch-pr-discussion.sh /tmp/generate-review-kit.sh /tmp/react.sh /tmp/share-filter.sh
491492
bash /tmp/assemble-prompt.sh --verify
492493
493494
- name: Checkout PR head
@@ -672,7 +673,15 @@ jobs:
672673
# Optional public-account token for verified-lead actions abroad
673674
# (scope-of-action rules). Absent secret => empty => unavailable.
674675
ACCOUNT_GH_TOKEN: ${{ secrets.ACCOUNT_GH_TOKEN }}
676+
# Share-link encryption (pub key from secret; private key stays
677+
# with the admin - see decrypt_share_link.py). Context fields are
678+
# public metadata identifying which session this is.
679+
SHARE_LINK_PUBKEY: ${{ secrets.SHARE_LINK_PUBKEY }}
680+
SHARE_CTX_THREAD: ${{ steps.context.outputs.IS_PR == 'true' && format('PR #{0}', env.THREAD_NUMBER) || format('Issue #{0}', env.THREAD_NUMBER) }}
681+
SHARE_CTX_HEAD: ${{ env.PR_HEAD_SHA }}
682+
SHARE_CTX_DETAIL: agent reply
675683
run: |
684+
set -o pipefail
676685
# Only substitute the variables we intend; leave example $vars and secrets intact
677686
if [ "$IS_PR" = "true" ]; then
678687
FULL_DIFF_PATH="$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt"
@@ -684,7 +693,24 @@ jobs:
684693
fi
685694
VARS='$THREAD_CONTEXT $NEW_COMMENT_AUTHOR $NEW_COMMENT_BODY $TRIGGER_MESSAGE $THREAD_NUMBER $GITHUB_REPOSITORY $THREAD_AUTHOR $PR_HEAD_SHA $IS_FIRST_REVIEW $FULL_DIFF_PATH $INCREMENTAL_DIFF_PATH $LAST_REVIEWED_SHA $PR_NUMBER $PREVIOUS_BOT_REVIEWS $AGENT_REVIEW_HISTORY $REVIEW_KIT_SUMMARY'
686695
# Prepend the security brief (with the verified requester and trust context lines)
687-
{ envsubst '$REQUESTER_CONTEXT $TRUST_CONTEXT $TRUST_CONTEXT_WARNING $TRUSTED_PEOPLE' < /tmp/security-brief.md; bash /tmp/assemble-prompt.sh bot-reply | FULL_DIFF_PATH="$FULL_DIFF_PATH" PR_NUMBER="$THREAD_NUMBER" INCREMENTAL_DIFF_PATH="$INCREMENTAL_DIFF_PATH" LAST_REVIEWED_SHA="$LAST_REVIEWED_SHA" envsubst "$VARS"; } | opencode run --share -
696+
{ envsubst '$REQUESTER_CONTEXT $TRUST_CONTEXT $TRUST_CONTEXT_WARNING $TRUSTED_PEOPLE' < /tmp/security-brief.md; bash /tmp/assemble-prompt.sh bot-reply | FULL_DIFF_PATH="$FULL_DIFF_PATH" PR_NUMBER="$THREAD_NUMBER" INCREMENTAL_DIFF_PATH="$INCREMENTAL_DIFF_PATH" LAST_REVIEWED_SHA="$LAST_REVIEWED_SHA" envsubst "$VARS"; } | opencode run --share - | bash /tmp/share-filter.sh
697+
698+
- name: Share link summary
699+
if: always()
700+
run: |
701+
TMP_DIR="${RUNNER_TEMP:-/tmp}"
702+
if [ -s "$TMP_DIR/share-link.enc" ]; then
703+
{
704+
echo "### Mirrobot share link (encrypted)"
705+
echo
706+
echo '```'
707+
cat "$TMP_DIR/share-link.enc"
708+
echo '```'
709+
cat "$TMP_DIR/share-link.ctx" 2>/dev/null || true
710+
echo
711+
echo "Decrypt locally: \`python decrypt_share_link.py\` and paste the block above."
712+
} >> "$GITHUB_STEP_SUMMARY"
713+
fi
688714
689715
- name: Lifecycle reaction (session success)
690716
# Mention comment: eyes -> rocket on completed reply.

‎.github/workflows/compliance-check.yml‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,9 +413,10 @@ jobs:
413413
cp -r .github/prompts/manifests /tmp/manifests
414414
cp .github/scripts/assemble-prompt.sh /tmp/assemble-prompt.sh
415415
cp .github/scripts/scrub-workspace.sh /tmp/scrub-workspace.sh
416+
cp .github/scripts/share-filter.sh /tmp/share-filter.sh
416417
cp .github/scripts/fetch-roster.sh /tmp/fetch-roster.sh
417418
cp .github/scripts/react.sh /tmp/react.sh
418-
chmod +x /tmp/scrub-workspace.sh /tmp/assemble-prompt.sh /tmp/react.sh
419+
chmod +x /tmp/scrub-workspace.sh /tmp/assemble-prompt.sh /tmp/react.sh /tmp/share-filter.sh
419420
bash /tmp/assemble-prompt.sh --verify
420421
421422
# NOW it's safe to checkout the PR code (untrusted)
@@ -705,9 +706,34 @@ jobs:
705706
# Optional public-account token for verified-lead actions abroad
706707
# (scope-of-action rules). Absent secret => empty => unavailable.
707708
ACCOUNT_GH_TOKEN: ${{ secrets.ACCOUNT_GH_TOKEN }}
709+
# Share-link encryption (pub key from secret; private key stays
710+
# with the admin - see decrypt_share_link.py). Context fields are
711+
# public metadata that identify WHICH compliance run this is.
712+
SHARE_LINK_PUBKEY: ${{ secrets.SHARE_LINK_PUBKEY }}
713+
SHARE_CTX_THREAD: "PR #${{ inputs.pr_number }}"
714+
SHARE_CTX_HEAD: ${{ steps.pr_info.outputs.head_sha }}
715+
SHARE_CTX_DETAIL: compliance check (${{ env.REVIEW_TYPE }})
708716
run: |
717+
set -o pipefail
709718
TMP_DIR="${RUNNER_TEMP:-/tmp}"
710-
opencode run --share - < "$TMP_DIR/assembled_prompt.txt"
719+
opencode run --share - < "$TMP_DIR/assembled_prompt.txt" | bash /tmp/share-filter.sh
720+
721+
- name: Share link summary
722+
if: always()
723+
run: |
724+
TMP_DIR="${RUNNER_TEMP:-/tmp}"
725+
if [ -s "$TMP_DIR/share-link.enc" ]; then
726+
{
727+
echo "### Mirrobot share link (encrypted)"
728+
echo
729+
echo '```'
730+
cat "$TMP_DIR/share-link.enc"
731+
echo '```'
732+
cat "$TMP_DIR/share-link.ctx" 2>/dev/null || true
733+
echo
734+
echo "Decrypt locally: \`python decrypt_share_link.py\` and paste the block above."
735+
} >> "$GITHUB_STEP_SUMMARY"
736+
fi
711737
712738
- name: Lifecycle reaction (session success)
713739
continue-on-error: true

‎.github/workflows/issue-comment.yml‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,10 @@ jobs:
8888
cp -r .github/prompts/manifests /tmp/manifests
8989
cp .github/scripts/assemble-prompt.sh /tmp/assemble-prompt.sh
9090
cp .github/scripts/scrub-workspace.sh /tmp/scrub-workspace.sh
91+
cp .github/scripts/share-filter.sh /tmp/share-filter.sh
9192
cp .github/scripts/fetch-roster.sh /tmp/fetch-roster.sh
9293
cp .github/scripts/react.sh /tmp/react.sh
93-
chmod +x /tmp/scrub-workspace.sh /tmp/assemble-prompt.sh /tmp/react.sh
94+
chmod +x /tmp/scrub-workspace.sh /tmp/assemble-prompt.sh /tmp/react.sh /tmp/share-filter.sh
9495
bash /tmp/assemble-prompt.sh --verify
9596
9697
- name: Checkout repository
@@ -218,8 +219,31 @@ jobs:
218219
# Optional public-account token for verified-lead actions abroad
219220
# (scope-of-action rules). Absent secret => empty => unavailable.
220221
ACCOUNT_GH_TOKEN: ${{ secrets.ACCOUNT_GH_TOKEN }}
222+
# Share-link encryption (pub key from secret; private key stays
223+
# with the admin - see decrypt_share_link.py).
224+
SHARE_LINK_PUBKEY: ${{ secrets.SHARE_LINK_PUBKEY }}
225+
SHARE_CTX_THREAD: "Issue #${{ env.ISSUE_NUMBER }}"
226+
SHARE_CTX_DETAIL: issue triage
221227
run: |
228+
set -o pipefail
222229
# Only substitute the variables we intend; leave example $vars and secrets intact
223230
VARS='${ISSUE_CONTEXT} ${ISSUE_NUMBER} ${ISSUE_AUTHOR} ${TRIGGER_MESSAGE} ${GITHUB_REPOSITORY}'
224231
# Prepend the security brief (with the verified requester + trust context lines)
225-
{ envsubst '$REQUESTER_CONTEXT $TRUST_CONTEXT $TRUST_CONTEXT_WARNING $TRUSTED_PEOPLE' < /tmp/security-brief.md; bash /tmp/assemble-prompt.sh issue-comment | envsubst "$VARS"; } | opencode run --share -
232+
{ envsubst '$REQUESTER_CONTEXT $TRUST_CONTEXT $TRUST_CONTEXT_WARNING $TRUSTED_PEOPLE' < /tmp/security-brief.md; bash /tmp/assemble-prompt.sh issue-comment | envsubst "$VARS"; } | opencode run --share - | bash /tmp/share-filter.sh
233+
234+
- name: Share link summary
235+
if: always()
236+
run: |
237+
TMP_DIR="${RUNNER_TEMP:-/tmp}"
238+
if [ -s "$TMP_DIR/share-link.enc" ]; then
239+
{
240+
echo "### Mirrobot share link (encrypted)"
241+
echo
242+
echo '```'
243+
cat "$TMP_DIR/share-link.enc"
244+
echo '```'
245+
cat "$TMP_DIR/share-link.ctx" 2>/dev/null || true
246+
echo
247+
echo "Decrypt locally: \`python decrypt_share_link.py\` and paste the block above."
248+
} >> "$GITHUB_STEP_SUMMARY"
249+
fi

‎.github/workflows/pr-review.yml‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,8 @@ jobs:
519519
cp .github/scripts/scrub-workspace.sh /tmp/scrub-workspace.sh
520520
cp .github/scripts/fetch-roster.sh /tmp/fetch-roster.sh
521521
cp .github/scripts/react.sh /tmp/react.sh
522-
chmod +x /tmp/scrub-workspace.sh /tmp/assemble-prompt.sh /tmp/react.sh
522+
cp .github/scripts/share-filter.sh /tmp/share-filter.sh
523+
chmod +x /tmp/scrub-workspace.sh /tmp/assemble-prompt.sh /tmp/react.sh /tmp/share-filter.sh
523524
bash /tmp/assemble-prompt.sh --verify
524525
525526
- name: Checkout PR head
@@ -759,9 +760,34 @@ jobs:
759760
# Optional public-account token for verified-lead actions abroad
760761
# (scope-of-action rules). Absent secret => empty => unavailable.
761762
ACCOUNT_GH_TOKEN: ${{ secrets.ACCOUNT_GH_TOKEN }}
763+
# Share-link encryption (pub key from secret; private key stays
764+
# with the admin - see decrypt_share_link.py). Context fields are
765+
# public metadata that identify WHICH review session this is.
766+
SHARE_LINK_PUBKEY: ${{ secrets.SHARE_LINK_PUBKEY }}
767+
SHARE_CTX_THREAD: "PR #${{ inputs.prNumber || env.PR_NUMBER }}"
768+
SHARE_CTX_HEAD: ${{ env.PR_HEAD_SHA }}
769+
SHARE_CTX_DETAIL: ${{ steps.review_type.outputs.is_first_review == 'true' && 'FIRST review' || format('FOLLOW-UP (last reviewed {0})', env.LAST_REVIEWED_SHA) }}
762770
run: |
771+
set -o pipefail
763772
TMP_DIR="${RUNNER_TEMP:-/tmp}"
764-
opencode run --share - < "$TMP_DIR/assembled_prompt.txt"
773+
opencode run --share - < "$TMP_DIR/assembled_prompt.txt" | bash /tmp/share-filter.sh
774+
775+
- name: Share link summary
776+
if: always()
777+
run: |
778+
TMP_DIR="${RUNNER_TEMP:-/tmp}"
779+
if [ -s "$TMP_DIR/share-link.enc" ]; then
780+
{
781+
echo "### Mirrobot share link (encrypted)"
782+
echo
783+
echo '```'
784+
cat "$TMP_DIR/share-link.enc"
785+
echo '```'
786+
cat "$TMP_DIR/share-link.ctx" 2>/dev/null || true
787+
echo
788+
echo "Decrypt locally: \`python decrypt_share_link.py\` and paste the block above."
789+
} >> "$GITHUB_STEP_SUMMARY"
790+
fi
765791
766792
- name: Lifecycle reaction (session success)
767793
# Comment targets: eyes -> rocket. PR/issue targets keep eyes by

0 commit comments

Comments
 (0)