Skip to content

Commit 42717ca

Browse files
author
Gianni Stubbe
committed
feat: add secure OpenCode GitHub agent
1 parent dd77ed8 commit 42717ca

26 files changed

Lines changed: 1407 additions & 151 deletions

.github/CODEOWNERS

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
* @33Fraise33
2+
3+
/.github/** @33Fraise33
4+
/.opencode/** @33Fraise33
5+
/AGENTS.md @33Fraise33
6+
/opencode.json @33Fraise33

.github/scripts/opencode-auth.mjs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { appendFileSync } from "node:fs"
2+
import { randomUUID } from "node:crypto"
3+
import { pathToFileURL } from "node:url"
4+
5+
const COMPONENTS = [
6+
"OPENAI_OAUTH_ACCESS_TOKEN",
7+
"OPENAI_OAUTH_REFRESH_TOKEN",
8+
"OPENAI_OAUTH_EXPIRES",
9+
"OPENAI_OAUTH_ACCOUNT_ID",
10+
]
11+
12+
export function buildAuthContent(env) {
13+
for (const name of COMPONENTS) {
14+
if (typeof env[name] !== "string" || env[name].length === 0) {
15+
throw new Error(`${name} is required.`)
16+
}
17+
}
18+
19+
if (!/^\d+$/.test(env.OPENAI_OAUTH_EXPIRES)) {
20+
throw new Error("OPENAI_OAUTH_EXPIRES must be a non-negative safe integer.")
21+
}
22+
const expires = Number(env.OPENAI_OAUTH_EXPIRES)
23+
if (!Number.isSafeInteger(expires)) {
24+
throw new Error("OPENAI_OAUTH_EXPIRES must be a non-negative safe integer.")
25+
}
26+
27+
return JSON.stringify({
28+
openai: {
29+
type: "oauth",
30+
refresh: env.OPENAI_OAUTH_REFRESH_TOKEN,
31+
access: env.OPENAI_OAUTH_ACCESS_TOKEN,
32+
expires,
33+
accountId: env.OPENAI_OAUTH_ACCOUNT_ID,
34+
},
35+
})
36+
}
37+
38+
export function main(env = process.env, log = console.log) {
39+
if (!env.GITHUB_ENV) throw new Error("GITHUB_ENV is required.")
40+
const content = buildAuthContent(env)
41+
const delimiter = `opencode_auth_${randomUUID()}`
42+
log(`::add-mask::${content}`)
43+
appendFileSync(env.GITHUB_ENV, `OPENCODE_AUTH_CONTENT<<${delimiter}\n${content}\n${delimiter}\n`)
44+
}
45+
46+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
47+
main()
48+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import assert from "node:assert/strict"
2+
import { mkdtemp, readFile, rm } from "node:fs/promises"
3+
import os from "node:os"
4+
import path from "node:path"
5+
import test from "node:test"
6+
7+
import { buildAuthContent, main } from "./opencode-auth.mjs"
8+
9+
const components = {
10+
OPENAI_OAUTH_ACCESS_TOKEN: "access-value",
11+
OPENAI_OAUTH_REFRESH_TOKEN: "refresh-value",
12+
OPENAI_OAUTH_EXPIRES: "123",
13+
OPENAI_OAUTH_ACCOUNT_ID: "account-value",
14+
}
15+
16+
test("constructs the exact OpenCode OAuth JSON", () => {
17+
assert.deepEqual(JSON.parse(buildAuthContent(components)), {
18+
openai: {
19+
type: "oauth",
20+
refresh: "refresh-value",
21+
access: "access-value",
22+
expires: 123,
23+
accountId: "account-value",
24+
},
25+
})
26+
})
27+
28+
test("requires every component and a non-negative safe integer expiration", () => {
29+
for (const name of Object.keys(components)) {
30+
assert.throws(() => buildAuthContent({ ...components, [name]: "" }), new RegExp(name))
31+
}
32+
for (const expires of ["-1", "1.2", "nope", "9007199254740992"]) {
33+
assert.throws(() => buildAuthContent({ ...components, OPENAI_OAUTH_EXPIRES: expires }), /safe integer/)
34+
}
35+
})
36+
37+
test("masks JSON before writing it with a random delimiter", async () => {
38+
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-auth-"))
39+
const envPath = path.join(directory, "env")
40+
const logs = []
41+
try {
42+
main({ ...components, GITHUB_ENV: envPath }, (line) => logs.push(line))
43+
const content = buildAuthContent(components)
44+
assert.deepEqual(logs, [`::add-mask::${content}`])
45+
assert.match(await readFile(envPath, "utf8"), /^OPENCODE_AUTH_CONTENT<<opencode_auth_[^\n]+\n.*\nopencode_auth_/)
46+
} finally {
47+
await rm(directory, { recursive: true, force: true })
48+
}
49+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { readFileSync } from "node:fs"
2+
import { pathToFileURL } from "node:url"
3+
4+
export async function fetchActorPermission({ apiUrl, repository, username, token, fetchImpl = fetch }) {
5+
if (!apiUrl || !repository || !username || !token) throw new Error("GitHub authorization configuration is incomplete.")
6+
const response = await fetchImpl(`${apiUrl}/repos/${repository}/collaborators/${username}/permission`, {
7+
headers: {
8+
Accept: "application/vnd.github+json",
9+
Authorization: `Bearer ${token}`,
10+
"X-GitHub-Api-Version": "2022-11-28",
11+
},
12+
})
13+
if (!response.ok) throw new Error(`GitHub collaborator permission lookup failed with HTTP ${response.status}.`)
14+
return response.json()
15+
}
16+
17+
export async function authorizeActor(event, options) {
18+
const username = event.comment?.user?.login
19+
if (options.mode === "plan" && username !== options.repositoryOwner) {
20+
throw new Error("Plan mode is limited to the repository owner.")
21+
}
22+
const { permission } = await fetchActorPermission({ ...options, username })
23+
if (!['admin', 'write'].includes(permission)) {
24+
throw new Error("OpenCode requires repository write or admin permission.")
25+
}
26+
}
27+
28+
export async function main(env = process.env) {
29+
if (!env.GITHUB_EVENT_PATH) throw new Error("GITHUB_EVENT_PATH is required.")
30+
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8"))
31+
await authorizeActor(event, {
32+
apiUrl: env.GITHUB_API_URL,
33+
mode: env.MODE,
34+
repository: env.GITHUB_REPOSITORY,
35+
repositoryOwner: env.GITHUB_REPOSITORY_OWNER,
36+
token: env.GITHUB_TOKEN,
37+
})
38+
}
39+
40+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
41+
await main()
42+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
4+
import { authorizeActor } from "./opencode-authorize-actor.mjs"
5+
6+
const event = { comment: { user: { login: "trusted-user" } } }
7+
const options = {
8+
apiUrl: "https://api.github.test",
9+
mode: "build",
10+
repository: "owner/repo",
11+
repositoryOwner: "owner",
12+
token: "token",
13+
}
14+
15+
function response(permission, username = "trusted-user") {
16+
return async (url) => ({
17+
ok: true,
18+
status: 200,
19+
json: async () => {
20+
assert.equal(url, `https://api.github.test/repos/owner/repo/collaborators/${username}/permission`)
21+
return { permission }
22+
},
23+
})
24+
}
25+
26+
test("authorizes only repository write and admin access", async () => {
27+
await assert.doesNotReject(() => authorizeActor(event, { ...options, fetchImpl: response("write") }))
28+
await assert.doesNotReject(() => authorizeActor(event, { ...options, fetchImpl: response("admin") }))
29+
await assert.rejects(() => authorizeActor(event, { ...options, fetchImpl: response("read") }), /write or admin/)
30+
})
31+
32+
test("limits plan mode to the repository owner", async () => {
33+
await assert.rejects(
34+
() => authorizeActor(event, { ...options, mode: "plan", repositoryOwner: "owner", fetchImpl: response("write") }),
35+
/repository owner/,
36+
)
37+
await assert.doesNotReject(
38+
() => authorizeActor({ comment: { user: { login: "owner" } } }, { ...options, mode: "plan", fetchImpl: response("write", "owner") }),
39+
)
40+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { appendFileSync, readFileSync } from "node:fs"
2+
import { randomUUID } from "node:crypto"
3+
import { pathToFileURL } from "node:url"
4+
5+
export const MAX_PROMPT_LENGTH = 8000
6+
const GITHUB_ATTACHMENT_URL = /https:\/\/github\.com\/user-attachments\//i
7+
8+
const MODELS = Object.freeze({
9+
sol: "openai/gpt-5.6-sol",
10+
terra: "openai/gpt-5.6-terra",
11+
luna: "openai/gpt-5.6-luna",
12+
})
13+
14+
const DEFAULT_MODELS = Object.freeze({
15+
plan: MODELS.sol,
16+
build: MODELS.terra,
17+
})
18+
19+
const ALLOWED_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"])
20+
21+
export function isAllowedAssociation(association) {
22+
return ALLOWED_ASSOCIATIONS.has(association)
23+
}
24+
25+
export function parseCommand(value) {
26+
if (typeof value !== "string") throw new Error("The OpenCode command must be text.")
27+
const body = value.replaceAll("\r\n", "\n")
28+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(body)) {
29+
throw new Error("The OpenCode command contains unsupported control characters.")
30+
}
31+
const match = body.match(
32+
/^\/(?:oc|opencode) +(plan|build)(?: +--model +(sol|terra|luna))? +(.+)$/s,
33+
)
34+
35+
if (!match) {
36+
throw new Error(
37+
"Expected `/oc <plan|build> [--model <sol|terra|luna>] <request>` with the command at the start of the comment.",
38+
)
39+
}
40+
41+
const mode = match[1]
42+
const modelAlias = match[2]
43+
const prompt = match[3].trim()
44+
if (!prompt) throw new Error("The OpenCode request must not be empty.")
45+
if (prompt.length > MAX_PROMPT_LENGTH) {
46+
throw new Error(`The OpenCode request must not exceed ${MAX_PROMPT_LENGTH} characters.`)
47+
}
48+
if (GITHUB_ATTACHMENT_URL.test(prompt)) {
49+
throw new Error("OpenCode commands cannot include GitHub attachment URLs.")
50+
}
51+
if (/^--model(?:\s|$)/i.test(prompt)) {
52+
throw new Error("Model must be one of: sol, terra, luna.")
53+
}
54+
55+
return {
56+
mode,
57+
model: modelAlias ? MODELS[modelAlias] : DEFAULT_MODELS[mode],
58+
prompt,
59+
}
60+
}
61+
62+
function writeOutput(outputPath, name, value) {
63+
const delimiter = `opencode_${randomUUID()}`
64+
appendFileSync(outputPath, `${name}<<${delimiter}\n${value}\n${delimiter}\n`)
65+
}
66+
67+
export function main(env = process.env) {
68+
if (!env.GITHUB_EVENT_PATH) throw new Error("GITHUB_EVENT_PATH is required.")
69+
if (!env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required.")
70+
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8"))
71+
const comment = event.comment
72+
if (!comment) throw new Error("This workflow only accepts GitHub comment events.")
73+
if (!isAllowedAssociation(comment.author_association)) {
74+
throw new Error(`User association ${comment.author_association ?? "UNKNOWN"} is not authorized.`)
75+
}
76+
77+
const command = parseCommand(comment.body)
78+
writeOutput(env.GITHUB_OUTPUT, "mode", command.mode)
79+
writeOutput(env.GITHUB_OUTPUT, "model", command.model)
80+
writeOutput(env.GITHUB_OUTPUT, "prompt", command.prompt)
81+
}
82+
83+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
84+
main()
85+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import assert from "node:assert/strict"
2+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
3+
import os from "node:os"
4+
import path from "node:path"
5+
import test from "node:test"
6+
7+
import { main, MAX_PROMPT_LENGTH, isAllowedAssociation, parseCommand } from "./opencode-command.mjs"
8+
9+
test("uses separate defaults and preserves command aliases", () => {
10+
assert.deepEqual(parseCommand("/oc plan inspect the role"), {
11+
mode: "plan",
12+
model: "openai/gpt-5.6-sol",
13+
prompt: "inspect the role",
14+
})
15+
assert.deepEqual(parseCommand("/opencode build implement the role"), {
16+
mode: "build",
17+
model: "openai/gpt-5.6-terra",
18+
prompt: "implement the role",
19+
})
20+
})
21+
22+
test("accepts only allowlisted model aliases", () => {
23+
assert.equal(parseCommand("/oc build --model luna make the change").model, "openai/gpt-5.6-luna")
24+
assert.throws(() => parseCommand("/oc build --model openai/gpt-5.6 make the change"))
25+
})
26+
27+
test("requires an exact command at the beginning", () => {
28+
assert.throws(() => parseCommand("please /oc plan inspect this"))
29+
assert.throws(() => parseCommand("/oc build"))
30+
assert.throws(() => parseCommand(" /oc plan inspect this"))
31+
})
32+
33+
test("normalizes CRLF and preserves multiline prompts", () => {
34+
assert.equal(parseCommand("/oc plan inspect this\r\nand include tests").prompt, "inspect this\nand include tests")
35+
})
36+
37+
test("enforces the prompt length limit", () => {
38+
assert.equal(parseCommand(`/oc plan ${"a".repeat(MAX_PROMPT_LENGTH)}`).prompt.length, MAX_PROMPT_LENGTH)
39+
assert.throws(() => parseCommand(`/oc plan ${"a".repeat(MAX_PROMPT_LENGTH + 1)}`))
40+
})
41+
42+
test("rejects unsupported control characters but permits tab and newline", () => {
43+
assert.equal(parseCommand("/oc plan line\n\tmore").prompt, "line\n\tmore")
44+
assert.throws(() => parseCommand("/oc plan nul\0value"))
45+
assert.throws(() => parseCommand("/oc plan escape\x1bvalue"))
46+
assert.throws(() => parseCommand("/oc plan delete\x7fvalue"))
47+
})
48+
49+
test("rejects GitHub attachment URLs before OpenCode can download them", () => {
50+
assert.throws(
51+
() => parseCommand("/oc plan inspect https://github.com/user-attachments/assets/unbounded"),
52+
/attachment URLs/,
53+
)
54+
})
55+
56+
test("allows only trusted repository associations", () => {
57+
assert.equal(isAllowedAssociation("OWNER"), true)
58+
assert.equal(isAllowedAssociation("MEMBER"), true)
59+
assert.equal(isAllowedAssociation("COLLABORATOR"), true)
60+
assert.equal(isAllowedAssociation("CONTRIBUTOR"), false)
61+
})
62+
63+
test("main writes multiline outputs and validates its environment", async () => {
64+
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-command-"))
65+
const eventPath = path.join(directory, "event.json")
66+
const outputPath = path.join(directory, "output")
67+
try {
68+
await writeFile(eventPath, JSON.stringify({ comment: { author_association: "OWNER", body: "/oc plan first\nsecond" } }))
69+
main({ GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath })
70+
const output = await readFile(outputPath, "utf8")
71+
assert.match(output, /prompt<<opencode_[^\n]+\nfirst\nsecond\nopencode_/)
72+
assert.throws(() => main({ GITHUB_OUTPUT: outputPath }), /GITHUB_EVENT_PATH/)
73+
assert.throws(() => main({ GITHUB_EVENT_PATH: eventPath }), /GITHUB_OUTPUT/)
74+
await writeFile(eventPath, "{")
75+
assert.throws(() => main({ GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath }), SyntaxError)
76+
} finally {
77+
await rm(directory, { recursive: true, force: true })
78+
}
79+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const BUILD_EDIT_PERMISSION = Object.freeze({
2+
"*": "allow",
3+
".git/**": "deny",
4+
".github/**": "deny",
5+
".opencode/**": "deny",
6+
"opencode.json": "deny",
7+
"AGENTS.md": "deny",
8+
"vault_pass.sh": "deny",
9+
".env": "deny",
10+
".env.*": "deny",
11+
"**/.env": "deny",
12+
"**/.env.*": "deny",
13+
})
14+
15+
export function isGithubActions(env = process.env) {
16+
return env.GITHUB_ACTIONS === "true"
17+
}
18+
19+
export function enableGithubBuildEdits(config) {
20+
const build = config?.agent?.["github-build"]
21+
if (!build?.permission || build.permission.edit !== "deny") {
22+
throw new Error("github-build must statically deny edits before guarded enablement")
23+
}
24+
25+
build.permission.edit = { ...BUILD_EDIT_PERMISSION }
26+
}

0 commit comments

Comments
 (0)