feat: seperate read/write token for enhanced security

This commit is contained in:
Alpha Nerd 2026-05-11 17:27:02 +02:00
parent fcfea07f01
commit 958b2ba9a9
Signed by: alpha-nerd
SSH key fingerprint: SHA256:QkkAgVoYi9TQ0UKPkiKSfnerZy2h4qhi3SVPXJmBN+M
2 changed files with 40 additions and 31 deletions

View file

@ -27,7 +27,11 @@ inputs:
default: "https://bitfreedom.net/code/"
forgejo_token:
description: "Forgejo PAT with repo scope (contents, pull-requests, issues)"
description: "Forgejo PAT used by the outer action for read operations (clone, fetch, read APIs). Recommended scopes: read:repository, read:issue, read:user. Never exposed to the opencode subprocess."
required: false
forgejo_push_token:
description: "Optional separate Forgejo PAT used only for write operations (git push, create/update comments, create MR). Recommended scopes: write:repository, write:issue. Falls back to forgejo_token if unset."
required: false
mentions:
@ -85,6 +89,7 @@ runs:
PROMPT: ${{ inputs.prompt }}
FORGEJO_API_URL: ${{ inputs.forgejo_api_url }}
FORGEJO_TOKEN: ${{ inputs.forgejo_token }}
FORGEJO_PUSH_TOKEN: ${{ inputs.forgejo_push_token }}
MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
run: |

View file

@ -121,7 +121,6 @@ const SERVER_URL = `http://${HOST}:${PORT}`
let proc: ReturnType<typeof spawn> | undefined
let accessToken: string
let commentId: number
let gitConfig: string
let session: { id: string; title: string; version: string }
let shareId: string | undefined
let exitCode = 0
@ -190,8 +189,16 @@ async function createAuthConfig(): Promise<string> {
try {
await createAuthConfig()
// Strip Forgejo write credentials from opencode's env so its bash tool cannot reach them.
const STRIP_FROM_AGENT_ENV = new Set(["FORGEJO_TOKEN", "FORGEJO_PUSH_TOKEN", "GITHUB_TOKEN"])
const agentEnv: NodeJS.ProcessEnv = {}
for (const [k, v] of Object.entries(process.env)) {
if (!STRIP_FROM_AGENT_ENV.has(k)) agentEnv[k] = v
}
proc = spawn(`opencode`, [`serve`, `--hostname=${HOST}`, `--port=${PORT}`], {
stdio: ["ignore", "inherit", "inherit"],
env: agentEnv,
})
assertContextEvent("issue_comment", "pull_request_review_comment", "pull_request_review")
assertPayloadKeyword()
@ -203,7 +210,7 @@ try {
accessToken = forgejoToken
const { userPrompt, promptFiles } = await getUserPrompt()
await configureGit(accessToken)
await configureGitIdentity()
await assertPermissions()
const comment = await createComment()
@ -295,7 +302,6 @@ try {
if (proc) {
proc.kill()
}
await restoreGitConfig()
}
process.exit(exitCode)
@ -311,7 +317,14 @@ function getForgejoConfig() {
if (!token) {
throw new Error(`Environment variable "FORGEJO_TOKEN" is not set`)
}
return { forgejoApiUrl: apiUrl, forgejoToken: token }
const pushToken = process.env["FORGEJO_PUSH_TOKEN"] || token
return { forgejoApiUrl: apiUrl, forgejoToken: token, forgejoPushToken: pushToken }
}
async function authedGit(token: string, args: string[]) {
const credential = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64")
const headerCfg = `http.https://${forgejoHost}/.extraheader=AUTHORIZATION: basic ${credential}`
return await $`git -c ${headerCfg} ${args}`
}
function forgejoApiUrl(...pathParts: string[]): string {
@ -322,11 +335,14 @@ function forgejoApiUrl(...pathParts: string[]): string {
}
async function forgejoFetch<T>(url: string, options?: RequestInit): Promise<T> {
const { forgejoToken } = getForgejoConfig()
const { forgejoToken, forgejoPushToken } = getForgejoConfig()
const method = (options?.method || "GET").toUpperCase()
const isWrite = method !== "GET" && method !== "HEAD"
const token = isWrite ? forgejoPushToken : forgejoToken
const res = await fetch(url, {
...options,
headers: {
Authorization: `token ${forgejoToken}`,
Authorization: `token ${token}`,
"Content-Type": "application/json",
...(options?.headers || {}),
},
@ -653,29 +669,12 @@ async function assertPermissions() {
// ─── Git operations ─────────────────────────────────────────────────────────
async function configureGit(appToken: string) {
console.log("Configuring git...")
const config = "http.https://github.com/.extraheader"
const ret = await $`git config --local --get ${config}`.catch(() => ({ stdout: "" }))
gitConfig = (ret.stdout as string)?.toString().trim() || ""
// Use Forgejo host for git credentials
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
const gitUrl = `http.https://${forgejoHost}/.extraheader`
await $`git config --local --unset-all ${config}`.catch(() => {})
await $`git config --local ${gitUrl} "AUTHORIZATION: basic ${newCredentials}"`
async function configureGitIdentity() {
console.log("Configuring git identity...")
await $`git config --global user.name "opencode-agent[bot]"`
await $`git config --global user.email "opencode-agent[bot]@users.noreply.${forgejoHost}"`
}
async function restoreGitConfig() {
if (gitConfig === undefined) return
console.log("Restoring git config...")
const config = "http.https://github.com/.extraheader"
await $`git config --local ${config} "${gitConfig}"`.catch(() => {})
}
async function checkoutNewBranch() {
console.log("Checking out new branch...")
const branch = generateBranchName("issue")
@ -686,8 +685,9 @@ async function checkoutNewBranch() {
async function checkoutLocalBranch(pr: ForgejoPullRequest) {
console.log("Checking out local branch...")
const branch = pr.head.ref
const { forgejoToken } = getForgejoConfig()
await $`git fetch origin --depth=100 ${branch}`
await authedGit(forgejoToken, ["fetch", "origin", "--depth=100", branch])
await $`git checkout ${branch}`
}
@ -695,10 +695,11 @@ async function checkoutForkBranch(pr: ForgejoPullRequest) {
console.log("Checking out fork branch...")
const remoteBranch = pr.head.ref
const localBranch = generateBranchName("pr")
const { forgejoToken } = getForgejoConfig()
const forkRemote = `https://${forgejoHost}/${pr.head.repo?.full_name}.git`
await $`git remote add fork ${forkRemote}`
await $`git fetch fork --depth=100 ${remoteBranch}`
await authedGit(forgejoToken, ["fetch", "fork", "--depth=100", remoteBranch])
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
}
@ -715,33 +716,36 @@ function generateBranchName(type: "issue" | "pr") {
async function pushToNewBranch(summary: string, branch: string) {
console.log("Pushing to new branch...")
const actor = useContext().actor
const { forgejoPushToken } = getForgejoConfig()
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.${forgejoHost}>"`
await $`git push -u origin ${branch}`
await authedGit(forgejoPushToken, ["push", "-u", "origin", branch])
}
async function pushToLocalBranch(summary: string) {
console.log("Pushing to local branch...")
const actor = useContext().actor
const { forgejoPushToken } = getForgejoConfig()
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.${forgejoHost}>"`
await $`git push`
await authedGit(forgejoPushToken, ["push"])
}
async function pushToForkBranch(summary: string, pr: ForgejoPullRequest) {
console.log("Pushing to fork branch...")
const { forgejoPushToken } = getForgejoConfig()
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${useContext().actor} <${useContext().actor}@users.noreply.${forgejoHost}>"`
await $`git push fork HEAD:${pr.head.ref}`
await authedGit(forgejoPushToken, ["push", "fork", `HEAD:${pr.head.ref}`])
}
async function branchIsDirty() {